Lesson 11 / 12
Database Access
A five-column query is run with a fake driver: three of the values coming from the driver's narrow type set are rebuilt into a custom Go type with the target variable's own Scan method, two already overlap with the driver's natural type. The limiting measurement runs to show that scanning looks at the target's type and an incompatible target fails at run time, while the connection pool measurement shows that an unclosed row set blocks the pool.
Published
In this lesson
- Measures how many of the five column values coming from the driver's narrow type set are rebuilt into a custom Go type through scanning
- Shows that scanning into an incompatible target fails not while the query is written, but at run time
- Limits by running that an unclosed row set blocks the connection pool, and that closing releases it
Contents
The previous lesson showed that a field extracted from a log line stays as text, and that
rebuilding it depends on the reading side’s own schema. This topic’s last lesson asks the same
question about a row passing through a database driver — but this time the schema does not sit
in text, but in the code itself: in the type of the target variable the caller hands to the
Scan call.
A database row is the most layered boundary measured in this topic. In the file and buffer
lesson the boundary was a single interface (Read/Write); here there are three layers — the
driver’s own narrow type set, database/sql’s generic mechanism carrying this set, and the
target type the caller hands to Scan. Each layer performs its own share of the conversion, and
this lesson shows separately where all three stand.
No real source is used. No real database is run in this lesson; the driver interface in the
standard library’s database/sql/driver package is implemented by a fake this lesson writes
itself. The fake driver returns fixed, hand-prepared two-row data; there is no network
connection, file, or external process.
The Fake Driver
Go’s standard library offers a generic database interface through the database/sql package;
real drivers (connection logic specific to a particular database product) are always distributed
as third-party packages. This lesson takes that distinction seriously: it implements the
interfaces in database/sql/driver (Driver, Conn, Rows) with a simple type it writes
itself, and imports no third-party driver at all.
// fake.go — the database/sql/driver interface is implemented with our own fake, no real database
package main
import (
"database/sql"
"database/sql/driver"
"fmt"
"io"
"time"
)
// UserID carries the row's first column: the driver carries it as an int64.
type UserID int64
func (k *UserID) Scan(source any) error {
v, ok := source.(int64)
if !ok {
return fmt.Errorf("UserID.Scan: unexpected source type %T", source)
}
*k = UserID(v)
return nil
}
// Amount carries an amount in cents; the driver carries this as an int64 too.
type Amount int64
func (t *Amount) Scan(source any) error {
v, ok := source.(int64)
if !ok {
return fmt.Errorf("Amount.Scan: unexpected source type %T", source)
}
*t = Amount(v)
return nil
}
// Timestamp converts a time the driver carries as a string into time.Time.
type Timestamp struct{ time.Time }
func (z *Timestamp) Scan(source any) error {
s, ok := source.(string)
if !ok {
return fmt.Errorf("Timestamp.Scan: unexpected source type %T", source)
}
parsed, err := time.Parse(time.RFC3339, s)
if err != nil {
return err
}
z.Time = parsed
return nil
}
var rowData = [][]driver.Value{
{int64(1), "pen", int64(1250), true, "2026-08-23T10:00:00Z"},
{int64(2), "notebook", int64(3990), false, "2026-08-20T09:30:00Z"},
}
type fakeDriver struct{}
func (fakeDriver) Open(name string) (driver.Conn, error) { return &fakeConn{}, nil }
type fakeConn struct{}
func (fakeConn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("fake driver: prepared statements not supported")
}
func (fakeConn) Close() error { return nil }
func (fakeConn) Begin() (driver.Tx, error) {
return nil, fmt.Errorf("fake driver: transactions not supported")
}
func (fakeConn) Query(query string, args []driver.Value) (driver.Rows, error) {
return &fakeRows{data: rowData, position: 0}, nil
}
type fakeRows struct {
data [][]driver.Value
position int
}
func (fakeRows) Columns() []string {
return []string{"id", "name", "price_cents", "active", "added"}
}
func (fakeRows) Close() error { return nil }
func (r *fakeRows) Next(dest []driver.Value) error {
if r.position >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.position])
r.position++
return nil
}
func init() {
sql.Register("fake", fakeDriver{})
}
This file contains no func main, so it is written to disk but not run; the measurements below
use it. The fakeRows.Next method imitates what a driver actually does: it returns each row in
a driver.Value slice, and driver.Value is deliberately a narrow type: only int64,
float64, bool, []byte, string, time.Time and nil can enter this type. The result that
matters for this lesson: whatever a column’s “real” meaning is at the source (a user ID, a
monetary amount, a timestamp), it is reduced to one of these seven types when it crosses the
driver boundary.
This narrowness is deliberate. The database/sql package offers a generic contract — every
driver, no matter which product it speaks, has to convert the row’s value into one of these
seven types. Calling code can use the same Query and Scan calls without knowing which driver
it works with, because the difference between drivers stays exactly behind this narrow
interface. This lesson repeats what a real driver does in a minimized form: fixed two-row data,
an Open that performs no real operation when attempting to open a connection, and a Next
that just returns its own array in order.
Five Columns, the Driver’s Narrow Type Set
SD17. — A five-column query is run; each column is scanned into its own Go type (UserID,
string, Amount, bool, Timestamp), and the result’s type is read with %T.
// measurement.go — five columns are scanned into their own types, reduced and non-reduced columns separated
package main
import (
"database/sql"
"fmt"
)
func main() {
db, _ := sql.Open("fake", "in-memory")
defer db.Close()
rows, _ := db.Query("select id, name, price_cents, active, added from products")
defer rows.Close()
for rows.Next() {
var id UserID
var name string
var price Amount
var active bool
var added Timestamp
if err := rows.Scan(&id, &name, &price, &active, &added); err != nil {
fmt.Println("scan error:", err)
continue
}
fmt.Printf("id=%d(%T) name=%s(%T) price=%d(%T) active=%t(%T) added=%s(%T)\n",
id, id, name, name, price, price, active, active, added.Format("2006-01-02"), added)
}
}
id=1(main.UserID) name=pen(string) price=1250(main.Amount) active=true(bool) added=2026-08-23(main.Timestamp) id=2(main.UserID) name=notebook(string) price=3990(main.Amount) active=false(bool) added=2026-08-20(main.Timestamp)
Three of the five columns — id, price_cents, added — come from the driver as int64,
int64, string respectively; none of them carries the target type’s name (UserID, Amount,
Timestamp). Each of these three columns is rebuilt by the target variable’s own Scan
method — UserID.Scan takes the raw int64 and converts it to its own type, Timestamp.Scan
takes the raw string and turns it into a time.Time with time.Parse. The other two columns —
name, active — already come from the driver in the same form as the target type (string,
bool); they need no special Scan method, a direct assignment is enough. The result: three of
the five columns are reduced and rebuilt through scanning, two already overlap with the driver’s
natural type.
These three Scan methods are the topic’s measurement axis’s clearest example of “rebuilt”: the
driver never says anywhere “this is a user ID” or “this is a time,” it only gives a raw number or
string. The schema that makes the rebuild possible does not sit in the driver, but in the target
variable’s own type and own method, the one the caller hands to Scan.
It is not a coincidence that the name and active columns need no special Scan method: the
standard library’s own conversion logic makes a direct assignment with no extra step whenever the
target variable’s type already overlaps with the driver.Value type coming from the driver. A
special Scan method only comes into play when the target type falls outside the seven types the
driver recognizes — this is why UserID, Amount and Timestamp carry their own methods, and
string and bool do not. This shows exactly where the driver’s narrow type set’s boundary is
drawn: the boundary is fixed at the driver, but every target type that crosses beyond that
boundary has to write its own crossing rule.
Limiting Measurement: Scanning Looks at the Target’s Type
The Scan call above rests on the assumption that the column order matches the target variable
order. When this match breaks — when a column’s target does not fit the driver.Value type that
column carries — the result is not a compile error, but a run-time error.
SD18. — The second column (name, a string) is mistakenly scanned into a UserID-typed
target; the query itself never changes, only the variable order in the Scan call gets mixed up.
// mismatched-target.go — the query is the same, only the Scan target gets mixed up; the error is at run time
package main
import (
"database/sql"
"fmt"
)
func main() {
db, _ := sql.Open("fake", "in-memory")
defer db.Close()
rows, _ := db.Query("select id, name, price_cents, active, added from products")
defer rows.Close()
rows.Next()
var id UserID
var wrongTarget UserID // the second column (name, a string) is attempted to be scanned in here
var price Amount
var active bool
var added Timestamp
err := rows.Scan(&id, &wrongTarget, &price, &active, &added)
fmt.Println("did scanning into a mismatched target give an error:", err != nil)
}
did scanning into a mismatched target give an error: true
The query text ("select id, name, ...") says nothing here — the column names are correct, the
query returns successfully from the fake driver. The error only appears when the Scan call
runs: wrongTarget is a UserID, but the value of the second column coming from the driver is a
string ("pen"), and UserID.Scan returns an error because it cannot convert this to an
int64. This is the boundary specific to this lesson on the topic’s measurement axis: scanning
looks at the target’s type, not the source’s — the column itself does not declare in
advance what type it is, it is only tested at run time against whether it fits the target. A
query does not look like it is written wrong; only the order of the Scan arguments has shifted,
and the compiler can never catch this shift because Scan’s signature is ...any — how many
arguments of what type are given is not checked at compile time.
This is a flaw that surfaces in the same way as the log field reading in the previous lesson.
There too, an error with a wrong schema (reading the success field as an int) came at run
time, not while the query or the log line was being written. The difference here is that the
link between the query’s select list and Scan’s argument list rests on position: there is
no check between the two lists that looks at the column name, only the order — and whether this
order is correct is only tested once the program is actually run.
Connection Pool and the Responsibility to Close
sql.DB is not a single connection, but a connection pool; the pool’s size can be limited
with SetMaxOpenConns. Until a query’s result (*sql.Rows) is closed, the connection that query
used does not return to the pool — forgetting to close blocks the pool.
SD19. — The pool is limited to a single connection. Without closing the first query’s result deliberately, a second query (with a short timeout) is tried on the same pool; then the first result is closed and a third query is tried.
// pool.go — an unclosed row set blocks a single-connection pool
package main
import (
"context"
"database/sql"
"fmt"
)
func main() {
db, _ := sql.Open("fake", "in-memory")
defer db.Close()
db.SetMaxOpenConns(1)
first, _ := db.Query("select id, name, price_cents, active, added from products")
first.Next() // keeps the connection open, not closed yet
fmt.Println("open connection count after the first query:", db.Stats().OpenConnections)
shortCtx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
_, secondErr := db.QueryContext(shortCtx, "select id, name, price_cents, active, added from products")
fmt.Println("did the second query fail while the pool was full:", secondErr != nil)
first.Close() // releases the connection back to the pool
third, thirdErr := db.Query("select id, name, price_cents, active, added from products")
fmt.Println("did the query succeed after closing:", thirdErr == nil)
if thirdErr == nil {
defer third.Close()
}
}
open connection count after the first query: 1 did the second query fail while the pool was full: true did the query succeed after closing: true
When the pool is limited to a single connection, that one connection stays busy for as long as
the first query’s result sits unclosed. The second query is tried with a context whose timeout
expires instantly (WithTimeout with a zero duration) — no real duration is measured, this only
makes the “no free connection in the pool” state come back immediately as an error. When
first.Close() is called, the connection returns to the pool and the third query runs without
trouble. This is the concrete meaning of “the responsibility to close”: for as long as a
*sql.Rows value stays unclosed, it keeps holding on to the connection underneath it — if the
pool’s size is limited, this forgetting can block other queries. The driver itself (fake or real)
plays no role here; managing the pool is entirely database/sql’s own responsibility.
This measurement deliberately reduced the pool to a single connection so that the blockage would
be visible with just one query; in a pool of real size, the same forgetting might not be noticed
until multiple queries pile up. Writing defer rows.Close() in the code — right after the
Query call, before the rows are used — is the standard way to prevent this forgetting; this is
also the pattern used throughout this lesson. Closing itself produces no measurable result, it
only determines whether the next query succeeds — exactly what this measurement shows.
Summary
- The type set the driver carries (
driver.Value) is limited to just seven types; whatever a column’s “real” meaning is, it is reduced to this set when crossing the driver boundary. - Three of the five columns (
id,price_cents,added) are rebuilt with the target type’s ownScanmethod; two (name,active) are assigned directly because they already overlap with the driver’s natural type. - Limiting measurement: scanning looks at the target’s type, not the source’s — an incompatible
target fails not while the query is written, but when
Scanis called, at run time. sql.DBmanages a connection pool; for as long as a query’s result stays unclosed, it keeps holding on to the connection underneath it.- An unclosed row set can block later queries in a pool of limited size; closing releases the connection back to the pool.
Next Step
In this lesson, scanning rebuilt a Go value from the driver’s narrow type set by looking at the
target’s type — the schema sat in the code itself, in the type of the variable handed to Scan.
The same question was asked in five separate places across this topic’s five lessons: in a file
and a buffer, in a resource embedded into a binary, in a time value turning into text, in a log
record carrying field names, and now in a database row. In all five, the value crossing the
boundary was not sufficient on its own; what made the rebuild possible was, every time, a schema
held by the other side.
Where the schema sat changed in every lesson. In the embedded resource, the schema was at compile
time — the target variable’s own type. In the time value, the schema was a layout the caller
already knew. In the log record, the schema was a conversion the reading code wrote by hand. In
this lesson, the schema was the target type’s own Scan method — bound, as a contract, to the
standard library itself. The common thread is always the same: the boundary itself never carried
the type information; what carried it was, every time, a piece of knowledge sitting on this side
of the boundary.
The next lesson closes this topic and carries the same question to the program’s own entry point: a command-line argument, too, just like a log line or a database column, arrives as text. How many types, and how much of them, can the program’s own parser rebuild from this text?
To keep your progress and take notes, Log in
My notes
Log in to take notes.