Skip to content
academia.sh

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 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?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close