skip to content

Why does sql.Open fail with an unknown-driver error in a binary that compiled fine?

level: seniorimportance: should knowfreq 46%

answer

  1. nothing in the code names it
  2. the underscore is the only edge
  3. registered by name from an init
  4. the error says forgotten import

basics

~20 s

The driver package was never linked into the binary. Nothing in your code names it, so the only edge pulling it in is a blank import, an underscore before the path, and a refactor can delete that line without producing any compile error.

solid answer

~50 s

`database/sql` looks the driver name you pass to `sql.Open` up in a registry that the driver package fills in from its own `init` by calling `sql.Register`. Your code imports `database/sql` and never the driver, so the single thing that puts the driver in the binary is a blank import, `_ "…/driver"`, conventionally in `main` or in the one package that builds the pool. Because no identifier references it, neither the compiler nor `go vet` can tell you it is gone: deleting it during a cleanup, or standing up a second binary whose main package never had it, yields a clean build and a run-time failure on the first open — the standard error even ends with the hint `(forgotten import?)`. The fixes are conventional: keep that import in exactly one place with a comment, and have a startup or CI check that really opens a connection so it fails before production does.

code

go · 10 lines
go
import (
	"image"
	_ "image/png" // registers the PNG decoder; nothing here names this package
	"io"
)

func first(r io.Reader) (image.Image, error) {
	img, _, err := image.Decode(r)
	return img, err
}

go deeper

for a junior

Know that a database driver is brought in with an underscore import and that losing that line breaks the program at run time rather than at compile time.

for a middle

Explain the mechanism end to end: the driver's init registers itself under a name, sql.Open is a lookup of that name, and the blank import is the only reason the init runs at all.

for a senior

Show the prevention as well as the diagnosis — one blank import in a known place with a comment, every binary routed through one setup package, and a check that really connects so the pipeline fails instead of the pager.

for a principal

Weigh registration by side effect against passing the implementation explicitly: the registry buys swappable drivers and costs you a dependency the compiler cannot see. Decide which binaries in your estate may rely on it.

## The symptom The binary builds. Tests that use an in-memory fake pass. In production, the first call to open the database returns an error of the form `sql: unknown driver "postgres" (forgotten import?)`. Nothing about the connection string, the network or the database server is wrong. ## What sql.Open actually does `database/sql` is a driver-agnostic layer. It does not know how to speak any database protocol. It keeps a package-level map from **driver name** (a plain string) to a driver implementation, and `sql.Open(driverName, dsn)` is a lookup in that map. If the name is not there, you get the error above — a pure bookkeeping failure with no I/O attempted. ## Who fills the registry The driver package does, from its own initialisation: a package-level `init` calls `sql.Register("postgres", &Driver{})`. That call runs when the driver package is initialised, which happens if and only if the package is part of the binary's import graph. So the chain is: your import graph decides whether the package is linked; being linked means its `init` runs; its `init` populating the registry is what makes the name resolvable. ## Why nothing in your code names the driver That is the entire design goal. Your code depends only on `database/sql` and on a string; swapping databases is meant to be a change of import line and connection string, not a change of call sites. The price is that the dependency exists only at *link* time, invisible to the type system. The only way to express "link this package in but do not name it" is the **blank import**: import _ "example.internal/db/driver" The blank identifier binds nothing, so Go's unused-import rule — which would otherwise reject an import no expression references — does not apply. ## Why the toolchain cannot protect you The compiler's guarantee is about *named* imports: every one of them is genuinely used. A blank import is precisely the case where that proof is unavailable. No expression refers to the package, so removing the line leaves a program that still type-checks, still links, still passes any test that does not actually open a database. `go vet` has no way to know that a string literal elsewhere in the program corresponds to a registration performed by a package you just dropped. ## How it goes missing in practice - A refactor moves database setup into a new package and the blank import stays behind in the old one. - Someone splits out a second binary — a migration tool, a backfill job — whose main package never got the line. - A cleanup pass deletes an import that "nothing uses", perfectly reasonably, because nothing does. - Two blank imports existed, one in a package that later stopped being imported, and the surviving path no longer reaches it. Notice the shape: the failure moves with the *import graph*, not with the code that opens the database. That is why it can appear during a change that touched neither the driver nor the query code. ## Diagnosing it fast Ask what names are actually registered: `sql.Drivers()` returns the sorted list of registered driver names. If it is empty or missing the one you passed, the answer is settled — no driver package was linked, and the question is which import went away. Then go looking for the blank import in the binary's main package and in whichever package constructs the pool. ## Preventing it - Put the blank import in **exactly one** place — the main package, or the single package that owns database setup — with a comment saying what it registers and that it must not be deleted. - Have every binary that talks to the database go through that one setup package, so there is one edge to lose rather than several. - Add a startup check, or a CI test, that actually opens a real connection. This converts an invisible link-time dependency into a loud failure in a pipeline instead of a page at 3am. - If you are onboarding someone, the comment is the whole documentation of a line that otherwise looks like garbage. ## The tradeoff worth naming Registration by side effect buys decoupling: the consumer knows a string, not a type, and drivers can be swapped or added without touching call sites. It costs a dependency the compiler cannot see and a failure mode that only shows at run time. An API that takes the implementation as a *value* — you construct the driver and hand it over — makes the same dependency visible to the type checker and removes this class of bug entirely, at the cost of naming the concrete driver in your code. ## The same shape elsewhere in the standard library It is not a database peculiarity. `_ "image/png"` registers a decoder so `image.Decode` recognises the format; `_ "net/http/pprof"` installs its handlers on the default request multiplexer purely as a side effect of being imported. Every one of them fails the same way: silently at build, loudly at run time.

  • How would you confirm the diagnosis in thirty seconds on a running service?
    Log `sql.Drivers()` at startup, or call it from a debug endpoint. It returns the registered driver names; an empty slice, or one missing the name you passed to `sql.Open`, proves no driver package was linked in. From there it is a search for the blank import in the binary's main package and in whatever package builds the pool.
  • Why does the compiler not complain that the blank-imported package is unused?
    Because the blank identifier binds no name. The unused-import rule is about a bound identifier that no expression references; with `_` there is no identifier at all. The package is still compiled, linked and initialised, which is exactly the effect you wanted.
  • How would you make a missing driver fail at build time instead?
    You cannot, while the lookup is by string — the dependency is a link edge, not a type. Either pass the driver implementation as a value so the compiler sees it, or accept the registry and cover it with a test that genuinely opens a connection, which turns the run-time failure into a pipeline failure.

saying these in an interview costs you the question

  • Says the compiler or go vet should have caught the missing import
  • Thinks sql.Open loads the driver dynamically at run time
  • Adds the blank import to every package that runs a query
  • Blames the connection string or an unreachable database first