AlgoMaster Logo

Connecting to a Database

High Priority17 min readUpdated June 6, 2026

A Go program talks to a database through a *sql.DB value returned by sql.Open. The call looks like it opens a connection, but it doesn't, it just sets up a pool that opens connections on demand. This chapter covers how sql.Open actually works, why Ping is the real connectivity test, how to read and write a DSN for sqlite, Postgres, and MySQL, and the errors you'll hit when something is wired up wrong.

What sql.Open Really Does

database/sql is a generic interface, and the driver does the real work. The function that ties them together is:

The first argument names a driver that's already been registered (more on registration below). The second is the DSN, a driver-specific string that says where the database lives and how to authenticate. The return value is a *sql.DB, which is the pool, not a single connection.

The surprising part is that sql.Open does almost no work. It validates that the driver name is registered, parses the DSN into something the driver understands, and returns a *sql.DB. It does not open a TCP connection, does not authenticate, does not check that the database exists. The first real network call happens later, when the pool needs a connection to run a query.

Run that with a DSN pointing at a nonexistent server and you still get no error from sql.Open. The error only shows up when the pool tries to actually use a connection. That's a deliberate design choice, it lets you create the pool at startup without worrying about whether the database happens to be reachable at that exact second, but it means sql.Open alone is not enough to know your program can talk to the database.

Why You Need db.Ping

Because sql.Open is lazy, you need a separate call to verify that the database is reachable. That call is db.Ping:

Ping pulls a connection from the pool (opening a new one if needed), sends a driver-specific liveness check, and returns the connection to the pool. If anything fails, the file isn't there, the server is down, the credentials are wrong, the DSN is invalid in a way that only the driver can detect, the error surfaces here.

The pattern is always: Open, check the error, defer Close, Ping, check the error. Anything that wants to fail fast at startup wants both checks. A program that skips the Ping will look healthy until the first real query, at which point it surfaces what should have been a startup error as a request error.

Adding a Timeout with PingContext

Ping blocks until the driver answers or the connection times out at the OS level. For a remote database, that can take a long time. PingContext lets you bound the wait with a context.Context:

If the database doesn't answer within two seconds, PingContext returns context.DeadlineExceeded and the program exits. That's the right behavior at startup, slow boots are worse than fast failures.

The same context.Context pattern flows through every method on *sql.DB. QueryContext, ExecContext, BeginTx, all of them respect the deadline. Wiring a context through your call chain is how Go services keep cascading failures from piling up.

DSN Formats: sqlite, Postgres, MySQL

The DSN (Data Source Name) is the second argument to sql.Open. Its format is set entirely by the driver, not by database/sql. Three of the most common Go drivers each have their own dialect.

DatabaseExample DSNNotes
sqlite (modernc.org/sqlite)file:store.db?cache=sharedfile: prefix is optional but explicit. Use :memory: for an in-memory DB.
sqlite (in-memory):memory:Whole DB lives in RAM. Lost when db.Close runs. Great for tests.
Postgres (github.com/lib/pq)postgres://user:pass@host:5432/dbname?sslmode=disableURL form. The driver also accepts host=... port=... user=... key/value form.
MySQL (github.com/go-sql-driver/mysql)user:pass@tcp(127.0.0.1:3306)/dbname?parseTime=trueDistinct format, not a URL. parseTime=true is needed to scan DATETIME into time.Time.

The code that follows sql.Open is the same for all three. That's the point of database/sql: the driver hides the dialect, your business logic is portable.

The same boilerplate for Postgres swaps the driver import, the driver name, and the DSN. The remaining lines do not change:

And for MySQL:

Two practical notes on DSNs.

First, never hardcode credentials in source. Read them from environment variables or a secrets manager and build the DSN at startup. A common pattern:

Second, query parameters in a DSN are the driver's tuning knobs. sslmode=disable (Postgres) turns off TLS, fine for local development, never for production. parseTime=true (MySQL) makes the driver convert DATETIME and TIMESTAMP columns into time.Time values instead of strings. cache=shared (sqlite) lets multiple connections in the same process share the same database state, which matters when more than one goroutine talks to the same file.

How Driver Registration Works

The blank import in _ "modernc.org/sqlite" is doing real work, even though the surrounding code never references the package. The trick is that Go runs every imported package's init functions before main starts, and driver packages register themselves in init.

The relevant database/sql function is:

A driver package calls this from its init function, passing a name and a driver.Driver implementation. The blank-import side effect, the only reason to import a package without using its symbols, is exactly to make those registrations happen.

Inside the sqlite driver, the registration looks roughly like:

sql.Open("sqlite", dsn) then looks up "sqlite" in the registry, finds &Driver{}, and uses it to interpret the DSN. The name is whatever the driver chose, it's not standardized. The sqlite driver registers itself as "sqlite", the Postgres driver lib/pq registers as "postgres", the MySQL driver go-sql-driver/mysql registers as "mysql".

You can inspect what drivers are registered in the current binary:

Add another driver import and a second name shows up in the list. Forget the blank import, and the list is empty. That's why missing imports show up as "unknown driver" errors at sql.Open time.

The diagram shows the full lifecycle from import to shutdown. The two boxes that surprise people are the gap between sql.Open and the first query (no I/O happens there), and the fact that errors caused by an unreachable database don't appear until the USE node, not at OPEN. Ping is the way to force that error path early.

Common Connection Errors

Most connection problems fall into a handful of categories, each with a specific error message and a specific fix.

SymptomRoot CauseFix
sql: unknown driver "sqlit" (forgotten import?)Driver name typo, or missing blank import.Check the spelling against sql.Drivers(). Add _ "modernc.org/sqlite".
sql.Open returns no error, Ping returns "no such host"DNS or hostname wrong.Check the host part of the DSN. Make sure the machine resolves the name.
Ping returns "connection refused"Database is not listening on that host/port.Confirm the server is running. Check the port. Check firewall rules.
Ping returns "authentication failed"Wrong username, password, or database name.Re-read credentials from env. Confirm the user exists with the right permissions.
Ping returns "permission denied" on sqliteFile system permissions on the .db file or its directory.chmod the file or its parent directory. Make sure the process user can read and write.
Ping returns "database is locked" on sqliteAnother writer is holding the file.Use cache=shared, set a busy timeout, or serialize writes.
Driver compiled but won't connect over TLSsslmode parameter wrong (Postgres) or missing TLS config (MySQL).Postgres: pick the right sslmode. MySQL: register a TLS config and reference it in the DSN.

The most instructive of these is the typo case, because it's deterministic and runnable. The following program intentionally misspells the driver name:

sql.Open checks the driver name first, so the typo surfaces immediately. The fix is either spelling the name right or adding the missing import.

The next instructive case is the lazy-open trap. Even with a totally invalid DSN, sql.Open can succeed and only Ping reports the problem. The exact behavior depends on the driver, but with sqlite, a path the process cannot create gets caught at Ping:

Notice that the program got past sql.Open with no complaint. The error only shows up at Ping. A program without that Ping would have happily logged "database initialized" and then failed on the first query, which is much harder to debug.

A sanity check that's slightly stronger than Ping is to run a trivial query like SELECT 1:

SELECT 1 runs a real round trip through the driver and the database, which catches problems that the driver's Ping implementation might miss (some drivers implement Ping as a local check rather than a server round trip). That said, for routine startup verification, Ping is usually enough.

Retrying Transient Failures

Real databases occasionally refuse connections for reasons that have nothing to do with your code. The server is restarting. The network blipped. A failover is in progress. A boot script tried to connect before the database container finished initializing. The right response is usually to retry with a short backoff, not to crash.

A small helper that retries Ping a few times is a good fit for startup:

The helper opens the pool, pings up to three times with exponential backoff, and gives up if either every attempt fails or the outer context expires. On a healthy database the first ping succeeds and the loop exits immediately. On a database that's still booting, the retries give it a window to come up.

Two design choices in that helper are worth pointing out. The outer ctx bounds the whole operation, so the caller can say "give up after ten seconds total" and the helper will respect that even mid-retry. The inner per-attempt context bounds each individual ping, so a hung server doesn't eat the whole retry budget on its first try. Wiring both is more code than a naive sleep loop, but it's the pattern that survives real production failures.

Pool tuning (max open connections, idle connections, lifetime) is a separate topic. The defaults work fine for early development. The retry helper here is about transient errors during the first connection, not about tuning the pool's steady-state behavior.

Closing the Pool

sql.DB has a Close method, and the idiom is to defer db.Close() right after Open succeeds:

Close waits for in-flight queries to finish, closes every connection in the pool, and marks the pool as unusable. After Close, any further method call on the *sql.DB returns sql.ErrConnDone or a similar error.

The lifetime of *sql.DB matters. It is meant to be long-lived. A typical Go service opens the pool once at startup, holds it for the entire run of the process, and closes it during shutdown. You don't open and close a *sql.DB per request, per function, or per query. Doing so means losing the entire point of having a pool, since every new pool starts cold and has to dial fresh connections.

A small program that wires this correctly looks like:

The App struct holds the pool. Construction creates it. The Close method tears it down. Every handler, every background worker, every helper that needs the database shares the same *sql.DB through the struct. That's the shape almost all Go services end up in once they grow past a single file.

One subtle detail: when the constructor fails the Ping, it calls db.Close() before returning the error. Otherwise the pool leaks. The pool's resources are not garbage collected just because no one holds a reference to the *sql.DB, the connections themselves live in cgo or in goroutines that won't shut down until Close is called.

Quiz

Connecting to Database Quiz

10 quizzes