AlgoMaster Logo

database/sql Basics

Last Updated: May 22, 2026

High Priority
9 min read

Go's standard library treats SQL databases as a first-class concept. The database/sql package gives you a single API that works against Postgres, MySQL, SQLite, SQL Server, and any other database that ships a Go driver. This chapter explains what the package actually is, how it talks to a database through a separate driver, why an *sql.DB value is a connection pool rather than a single connection, and how to write the smallest end-to-end program that opens a database and creates a table.

What database/sql Is

The database/sql package is an interface, not a database client. It defines the types and methods that any SQL database in Go is expected to expose: DB, Row, Rows, Tx, Stmt, and a handful of others. It does not know how to speak the Postgres wire protocol, the MySQL wire protocol, or the SQLite file format. That work lives in a separate package called a driver.

When you import database/sql on its own, the program compiles but can't talk to any database. You also need to import a driver for the database you're using. The driver registers itself with database/sql during its init function, after which sql.Open can hand you a working *sql.DB.

This split is the whole point of the package. Your business logic depends on database/sql, not on a specific driver, so swapping Postgres for MySQL or pointing the same code at SQLite for tests is a one-import change. The trade-off is that database/sql only exposes what every SQL database has in common. Database-specific features (Postgres LISTEN/NOTIFY, MySQL session variables, SQLite virtual tables) are reachable only through driver-specific APIs.

The diagram shows the four layers. Your code calls methods on *sql.DB. Those methods go through the database/sql package, which delegates to whichever driver you imported. The driver does the actual network I/O or file I/O against the database. The reason you can switch drivers without rewriting application code is that everything above the driver layer is the same.

The Driver Model and the Blank Import

A driver in database/sql terms is any package that implements the driver.Driver interface and calls sql.Register("name", &driverImpl{}) from its init function. From your application's point of view, the entire interaction looks like this:

The underscore in front of "modernc.org/sqlite" is the blank import. Go normally requires every imported package to be used somewhere in the file, but the blank identifier suppresses that check. You're importing the package purely for the side effect of running its init function, which calls sql.Register("sqlite", ...). After that call, the string "sqlite" is wired up to the driver inside database/sql, and you never reference any name from the modernc.org/sqlite package directly.

This is the same pattern you'd use for any other driver. The table below shows the popular drivers and what string they register themselves under.

Driver PackageDatabaseDriver NamePure Go?
modernc.org/sqliteSQLite"sqlite"Yes
github.com/mattn/go-sqlite3SQLite"sqlite3"No (CGO)
github.com/jackc/pgx/v5/stdlibPostgres"pgx"Yes
github.com/lib/pqPostgres"postgres"Yes
github.com/go-sql-driver/mysqlMySQL"mysql"Yes
github.com/microsoft/go-mssqldbSQL Server"sqlserver"Yes

We'll use modernc.org/sqlite throughout this section because it's pure Go, needs no external server, and runs on every platform without configuration. The driver name is "sqlite" (not "sqlite3", that's a different driver). Everything you learn about database/sql with this driver carries over to Postgres or MySQL by changing the import and the connection string.

The smallest program that registers the driver and asks database/sql what drivers it knows about looks like this:

To run this, you need a Go module with the dependency declared:

sql.Drivers() returns the list of names registered with the package, which is useful when you're debugging "I imported the driver but Go says it can't find it". If your driver name isn't in that list, the blank import didn't run, usually because the import line was deleted or go mod tidy removed it as unused.

sql.DB Is a Connection Pool

The single most important fact about database/sql is that *sql.DB is not a connection. It's a pool of connections that the package manages on your behalf.

When you call sql.Open, you get back a *sql.DB value. No network round trip has happened yet. The function records the driver name, parses (or trusts) the DSN string, and returns. The first time you do something that actually needs a connection (db.Exec, db.Query, db.Ping), the pool either reuses an idle connection or opens a fresh one. When the operation finishes, the connection goes back into the pool for the next caller.

The pool holds zero or more connections at any moment. Each connection is either idle (sitting in the pool waiting to be borrowed) or busy (currently running a query for some caller). When your code calls db.Exec, the pool picks an idle connection if one exists, opens a new one if none are idle and the maximum hasn't been reached, or blocks until a connection frees up if the maximum is reached. When the operation returns, the connection goes back into the idle set.

Three practical consequences fall out of this design.

`sql.DB` is safe for concurrent use by many goroutines. You don't need a mutex around it. Two HTTP handlers running on different goroutines can both call db.Exec at the same time, and the pool will hand each one its own connection. This is the right way to use database/sql: one global *sql.DB shared across the program, not one per request.

Open it once, close it once. A typical Go program calls sql.Open once at startup, stores the *sql.DB somewhere (a global, a struct field, a function parameter), uses it for the life of the program, and calls db.Close at shutdown. Calling sql.Open per request creates a new pool per request, which defeats the entire point of the pool and quickly exhausts the database's connection limit.

The pool is configurable but has sensible defaults. *sql.DB has methods like SetMaxOpenConns, SetMaxIdleConns, and SetConnMaxLifetime that tune pool behavior. The defaults are conservative and fine for development.

Lifecycle: Open, Defer Close, Share

The standard pattern for using *sql.DB in a real program has three steps: open it once at startup, defer the close, and share the pointer everywhere it's needed. Here's that pattern in its simplest form:

The program calls sql.Open with two arguments: the driver name ("sqlite", the same string the driver registered under) and a data source name ("store.db", which for the sqlite driver is the path to the database file). It captures any error, defers the close, and then just logs that the handle is ready.

A surprise hiding in this example is that sql.Open does not connect to the database. It validates the driver name and stores the DSN, but no I/O happens. If you pass "store.db" and that file isn't writable, you won't find out until the first real query. If you pass a Postgres DSN to a database server that's offline, sql.Open still returns a *sql.DB with no error. The actual connection attempt is lazy.

To force a connection attempt right now, call db.Ping(). Ping borrows a connection from the pool, exchanges a no-op message with the database, and returns the connection. If anything is wrong (file permissions, DSN syntax, server unreachable), Ping returns the error.

For now, write programs that just call sql.Open, defer close, and use db.Exec. If the database is configured correctly, Exec will surface any connection problem the same way Ping would.

A common shape that you'll see in real Go services is the following: an OpenStore function that wraps the sql.Open boilerplate and returns the *sql.DB to the caller.

The function wrapper looks pointless on its own, and at this stage it is. Hiding the construction inside a single function from the start is the convention that lets future changes happen in one place rather than wherever sql.Open happens to be called.

database/sql vs an ORM

database/sql is one option for talking to a SQL database from Go. The other common option is an ORM (object-relational mapper) or query builder like GORM (gorm.io/gorm), ent (entgo.io/ent), or sqlx (github.com/jmoiron/sqlx, which is more of a thin wrapper than a full ORM). Choosing between them depends on what you're optimizing for.

Concerndatabase/sqlORM (GORM, ent)
Control over SQLYou write every query by handThe ORM generates queries from method calls or schema definitions
Performance overheadMinimal, you pay for what you useReflection, query generation, hooks add measurable overhead
Learning curveRead package docs, write SQLLearn the ORM's API, conventions, query builder syntax, hooks, migrations
Schema migrationsNot included; use a separate tool like golang-migrateOften included in the ORM
Type safetyManual: you Scan into typed variablesStrong: generated structs match the schema
DebuggingErrors point at the SQL you wroteErrors point at generated SQL, which can be hard to read
Database-specific featuresUse driver-specific APIs when neededOften abstracted away or harder to access
When to useYou know SQL and want predictable queriesLots of CRUD against a stable schema; you want generated types

The Go community leans more heavily on database/sql plus a small helper like sqlx than the equivalent Java or Python community leans on JPA or Django ORM. This is a cultural choice as much as a technical one. Go favors explicit code over magic, and database/sql is as explicit as it gets: every query is visible, every type conversion is in front of you, and there are no hidden side effects from struct tags or hooks.

Learn database/sql first, even if you end up using an ORM later. Every ORM in Go is built on top of database/sql, and understanding the layer underneath makes the ORM less mysterious when something goes wrong. If you find yourself writing the same Scan boilerplate over and over, sqlx closes most of that gap without taking control of your SQL away. If you have many tables and lots of similar CRUD, an ORM like ent (which generates strongly-typed code from a schema) is reasonable. The rest of this section focuses on database/sql because that's the foundation everything else is built on.

A Minimal End-to-End Example

Putting all the pieces together, here is a complete program that opens a SQLite database, creates a products table if it doesn't already exist, inserts one row, and closes. Save it as main.go in a Go module that has modernc.org/sqlite installed.

Walking through it step by step. The blank import of modernc.org/sqlite registers the driver under the name "sqlite". The first sql.Open call returns a *sql.DB with no I/O. The defer db.Close() schedules the pool teardown for when main returns.

The first db.Exec runs the CREATE TABLE IF NOT EXISTS statement. Exec is the method you use for statements that don't return rows: schema changes, inserts, updates, deletes. It returns a sql.Result (which carries LastInsertId and RowsAffected) and an error. We discard the result with _ because the table creation doesn't need it.

The second db.Exec inserts a row. The placeholders in the SQL are ?, which is the placeholder syntax most SQLite drivers accept. For Postgres you'd write $1, $2, $3 instead; for MySQL the ? form works. Either way, the values come as additional arguments after the SQL string, and the driver substitutes them safely (no SQL injection).

After the program runs, you'll have a store.db file in the current directory containing a single row in the products table. Run the program a second time and it adds another row, because the INSERT runs every time but the CREATE TABLE IF NOT EXISTS is idempotent. You can inspect the file with the sqlite3 command-line tool if it's installed:

Each run added one more Notebook row. The first column is the auto-assigned id. For this chapter the goal is just to confirm that the pipeline works end to end: driver registered, pool opened, statement executed, file written.