Right, let’s get our hands dirty. Before you can ask the database anything, you need two things: a driver and a connection. It sounds simple, and it is, but the Go way of doing it is a little… unique. Let’s just say the designers had a very strong opinion about avoiding magic, and they stuck to it.

First, the driver. Think of it as the translator for a specific database dialect (PostgreSQL, MySQL, etc.). The database/sql package is the generic, all-powerful boss who only speaks in abstract concepts like “connections” and “results.” The driver is the poor soul who has to actually implement those concepts for a specific database.

The Implicit Driver Registration Voodoo

Here’s the first bit of weirdness: you almost never call a RegisterDriver function directly. Instead, you import the driver package for its side effects. Yes, you read that correctly. You import it so that its init() function can secretly register itself with database/sql behind the scenes. It feels a bit like casting a spell by including the right ingredient.

You’ll typically see it done like this, with a blank identifier (_):

import (
    "database/sql"
    _ "github.com/lib/pq" // PostgreSQL driver
    // _ "github.com/go-sql-driver/mysql" // MySQL driver
)

func main() {
    // ... now we can use sql.Open with "postgres"
}

That _ is crucial. It tells Go, “I’m only importing this for its side effects, not for any functions I plan to call directly.” Without it, your linter will (rightfully) complain about an unused import. The driver’s init() function looks something like this internally:

// Inside github.com/lib/pq/driver.go
func init() {
    sql.Register("postgres", &Driver{})
}

It’s a pattern that rubs some people the wrong way, but it works. Just remember: if you don’t import the driver, sql.Open will happily return a connection object that will fail spectacularly the first time you try to use it. It’s a classic “successful but utterly useless” response.

Opening a Connection (Well, Actually, It’s Not)

Now, let’s open a connection. Or so you think.

db, err := sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=verify-full")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

Here’s the most important thing to internalize right now: sql.Open does not create a connection. It does not even talk to the database. All it does is create a *sql.DB object and validate the provided Data Source Name (DSN, or connection string). The actual first connection is established lazily, only when you execute your first query.

Why this sleight of hand? Performance and connection pool management. The *sql.DB object it returns is not a single connection; it’s a whole manager for a pool of connections. This is brilliant because it means the overhead of creating the pool and its connections is spread out across your first few requests, not all slammed into your startup time.

The db.Close() is still vital, though. You’re not closing a single connection; you’re telling the entire connection pool to shut down operations and release all its resources.

Crafting the DSN: The House of Horrors

If there’s one truly absurd part of this whole process, it’s the DSN. There is no standard. It’s a free-for-all. Every driver makes up its own format, and it’s a constant source of bugs.

  • PostgreSQL (github.com/lib/pq): Uses a space-separated, URL-like string of key=value pairs. Example: "user=your_user dbname=your_db sslmode=disable"
  • MySQL (github.com/go-sql-driver/mysql): Uses a familiar URL format, but with a protocol prefix and a different way of specifying parameters. Example: "username:password@tcp(127.0.0.1:3306)/dbname?parseTime=true"
  • SQLite (modernc.org/sqlite): Just wants a path to the file. Example: "test.db"

There is no consistency. You just have to go read the docs for your specific driver. I don’t know why they did this. It’s a clear questionable choice, but we’re stuck with it. Always, always check the driver’s documentation for the correct format.

The Lazy Connection Pitfall

Because connections are established on first use, sql.Open will not tell you if your credentials or network configuration are wrong. Your code will start up perfectly happily. The error will only surface moments later, probably in the middle of a critical HTTP request, when you try to db.Query() for the first time.

This is a common foot-gun. You must always check the viability of the connection immediately after sql.Open by using Ping() or PingContext().

db, err := sql.Open("postgres", connStr)
if err != nil {
    log.Fatal(err) // This only catches DSN format errors
}
defer db.Close()

// This is the crucial step that actually talks to the DB.
err = db.Ping()
if err != nil {
    log.Fatal(err) // This catches auth & network errors
}

fmt.Println("Database connection is alive!")

Consider Ping non-optional. It’s your one chance to fail fast during application startup instead of failing mysteriously later. Your ops team will thank you. Actually, they won’t, because they’ll never see the error. And that’s the point.