---
title: 'Database Access'
source: 'https://academia.sh/en/courses/go-applications/database-access'
course: 'The Standard Library and Network Applications'
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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.

```go
// 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`.

```go
// 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.

```go
// 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.

```go
// 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 own
  `Scan` method; 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 `Scan` is called, at run time.
- `sql.DB` manages 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?
