Prepared statements

On this page

The same on TimescaleDB. TimescaleDB runs this example unchanged: the SQL and the Go that sgl generates are identical to the PostgreSQL version. Only dialect: in sgl.yaml changes.

A prepared statement is parsed and planned by the database once, then run as often as you like with different values. With prepared: true under go: in sgl.yaml, sgl generates a Prepare function that prepares every static query in the package when the program starts. You get two things from it: a query run over and over skips the parsing each time, and a query that no longer fits the schema fails at start-up, where you will see it, rather than on the first request that happens to use it.

pgx's database/sql driver already keeps a prepared statement per connection for each query it runs, so on PostgreSQL the check at start-up is most of the gain.

The example keeps a table of high-tide times for three harbours, loads three days of them, and looks each one up with the same query.

Turning it on

The option is one line in the go: section:

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: tides
    dialect: postgres
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      driver: database/sql
      prepared: true
    migrate:
      dsn: ${env:DATABASE_URL}

Nothing changes in the SQL. The queries are written exactly as they would be without it:

db/queries/tide_times.sql

-- sgl.query(HighTide, one)
SELECT high_at FROM tide_times WHERE port = $1 AND day = $2;

What sgl generates

The generated methods do not change either: HighTide still passes its SQL text to QueryRowContext. What is new is a file, prepared.go, with a Prepare function and a PreparedDBTX type.

internal/store/prepared.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
)

type PreparedDBTX struct {
	db    *sql.DB
	stmts map[string]*sql.Stmt
	mu    sync.RWMutex
}

func Prepare(ctx context.Context, db *sql.DB) (*PreparedDBTX, error) {
	staticQueries := []string{addTideTimeSQL, highTideSQL}
	stmts := make(map[string]*sql.Stmt, len(staticQueries))
	for _, query := range staticQueries {
		statement, err := db.PrepareContext(ctx, query)
		if err != nil {
			return nil, errors.Join(fmt.Errorf("preparing statement: %w", err), sglCloseStatements(stmts))
		}
		stmts[query] = statement
	}
	return &PreparedDBTX{db: db, stmts: stmts, mu: sync.RWMutex{}}, nil
}

func (prepared *PreparedDBTX) cachedStatement(query string) *sql.Stmt {
	prepared.mu.RLock()
	defer prepared.mu.RUnlock()
	return prepared.stmts[query]
}

func (prepared *PreparedDBTX) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.ExecContext(ctx, query, arguments...)
	}
	return statement.ExecContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.QueryContext(ctx, query, arguments...)
	}
	return statement.QueryContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.QueryRowContext(ctx, query, arguments...)
	}
	return statement.QueryRowContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) BeginTx(ctx context.Context, options *sql.TxOptions) (*sql.Tx, error) {
	return prepared.db.BeginTx(ctx, options)
}

func (prepared *PreparedDBTX) PingContext(ctx context.Context) error {
	return prepared.db.PingContext(ctx)
}

func (prepared *PreparedDBTX) UnderlyingDB() *sql.DB {
	return prepared.db
}

func (prepared *PreparedDBTX) Close() error {
	prepared.mu.Lock()
	defer prepared.mu.Unlock()
	err := sglCloseStatements(prepared.stmts)
	prepared.stmts = make(map[string]*sql.Stmt)
	return err
}

func (prepared *PreparedDBTX) WrapDBTX(inner any) any {
	transaction, ok := inner.(*sql.Tx)
	if !ok {
		return inner
	}
	return &preparedTx{source: prepared, transaction: transaction}
}

type preparedTx struct {
	source      *PreparedDBTX
	transaction *sql.Tx
}

func (bound *preparedTx) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.ExecContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).ExecContext(ctx, arguments...)
}

func (bound *preparedTx) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.QueryContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).QueryContext(ctx, arguments...)
}

func (bound *preparedTx) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.QueryRowContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).QueryRowContext(ctx, arguments...)
}

func sglCloseStatements(stmts map[string]*sql.Stmt) error {
	var closeErrors []error
	for _, statement := range stmts {
		if err := statement.Close(); err != nil {
			closeErrors = append(closeErrors, err)
		}
	}
	return errors.Join(closeErrors...)
}

Prepare prepares each static query and keeps the statements in a map keyed by their SQL text. If any query fails to prepare, it closes the ones it has made and returns the error. PreparedDBTX has the same three methods as any DBTX, so you pass it to store.New in place of the *sql.DB. When a method runs a query, PreparedDBTX looks the text up and runs the prepared statement instead; text it did not prepare, such as a runtime builder's SQL, goes straight to the database. Close releases the statements.

The database holds prepared statements per connection, and database/sql keeps a pool of connections, so it prepares each statement again on every pooled connection it uses. That happens behind the *sql.Stmt, without any code of yours.

Transactions

RunInTx needs the *sql.DB itself to begin a transaction, and UnderlyingDB hands it back.

The transaction keeps the prepared statements. PreparedDBTX has a WrapDBTX method, which store.New notices and remembers. When RunInTx or WithTx binds the queries to a transaction, it passes the *sql.Tx through WrapDBTX, which returns a preparedTx: for each query it finds the prepared statement and binds it to the transaction with StmtContext, so the statement runs inside the transaction without being prepared again.

Using it

The program prepares the queries once, loads the tide times in a transaction, then runs HighTide nine times, once for each harbour and day.

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/postgres/025_prepared_statements/internal/store"
)

const (
	// days is how many days of tide times the example loads for each port.
	days = 3

	// minutesPerDay wraps tide times past midnight.
	minutesPerDay = 24 * 60

	// tideDrift is roughly how much later high tide comes each day, in minutes.
	tideDrift = 50
)

// ports lists the harbours and the minute of their first high tide.
var ports = []struct {
	name  string
	first int
}{
	{name: "Padstow", first: 5*60 + 12},
	{name: "Tenby", first: 6*60 + 40},
	{name: "Whitby", first: 11*60 + 3},
}

// run prepares every query once, loads a few days of tide times in a transaction, then
// looks up each port's high tide for each day through the prepared statements.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the tide table.
//
// Returns error when preparing or a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	// With go.prepared: true, sgl generates Prepare. It prepares every static query in the
	// package up front, so a query that no longer matches the schema fails here, at
	// start-up, rather than on first use. Close releases the statements. pgx's driver
	// already keeps a prepared statement per connection for each query it runs, so on
	// PostgreSQL that check at start-up is most of the gain.
	prepared, err := store.Prepare(ctx, database)
	if err != nil {
		return fmt.Errorf("preparing queries: %w", err)
	}
	defer func() { _ = prepared.Close() }()

	// *store.PreparedDBTX is a DBTX: every method of these Queries runs its prepared
	// statement instead of sending the SQL text again.
	queries := store.New(prepared)

	// RunInTx needs the *sql.DB itself, which UnderlyingDB returns. PreparedDBTX also has a
	// WrapDBTX method, so the Queries that RunInTx binds to the transaction keep using the
	// prepared statements, each bound to the *sql.Tx with StmtContext.
	err = queries.RunInTx(ctx, prepared.UnderlyingDB(), func(inTransaction *store.Queries) error {
		return loadTideTimes(ctx, inTransaction)
	})
	if err != nil {
		return fmt.Errorf("loading tide times: %w", err)
	}

	fmt.Fprint(output, "         ")
	for day := 1; day <= days; day++ {
		fmt.Fprintf(output, "  day %d", day)
	}
	fmt.Fprintln(output)

	// The same HighTide statement runs once per port and day.
	for _, port := range ports {
		fmt.Fprintf(output, "%-9s", port.name)
		for day := int64(1); day <= days; day++ {
			tide, err := queries.HighTide(ctx, store.HighTideParams{Port: port.name, Day: day})
			if err != nil {
				return fmt.Errorf("looking up %s on day %d: %w", port.name, day, err)
			}
			fmt.Fprintf(output, "  %s", tide.HighAt)
		}
		fmt.Fprintln(output)
	}
	return nil
}

// loadTideTimes adds a high tide for every port on every day, each a little later than
// the day before.
//
// Takes queries (*store.Queries) which add the rows.
//
// Returns error when a row cannot be added.
func loadTideTimes(ctx context.Context, queries *store.Queries) error {
	for _, port := range ports {
		for day := range days {
			minute := (port.first + day*tideDrift) % minutesPerDay
			err := queries.AddTideTime(ctx, store.AddTideTimeParams{
				Port:   port.name,
				Day:    int64(day + 1),
				HighAt: fmt.Sprintf("%02d:%02d", minute/60, minute%60),
			})
			if err != nil {
				return err
			}
		}
	}
	return nil
}

What it prints

           day 1  day 2  day 3
Padstow    05:12  06:02  06:52
Tenby      06:40  07:30  08:20
Whitby     11:03  11:53  12:43

Try it

Remove prepared: true and run sgl generate. prepared.go is deleted and nothing else in the package changes, which shows that preparing is entirely a matter of the DBTX you pass to store.New. The program then stops compiling at store.Prepare; pass the *sql.DB to store.New directly and it runs as before, without prepared statements.

Across engines

HighTide has the same Go types on every engine that has this example.

CockroachDB and TimescaleDB run the PostgreSQL version unchanged.

Compare the whole example side by side with SQLite.

Where the example is missing:

  • Not on ClickHouse. ClickHouse prepares only INSERT batches.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/postgres && go run ./025_prepared_statements

Its test runs it too, and checks what it prints against output.txt:

cd seagull/examples/postgres && SGL_EXAMPLE_ENGINE=timescaledb SGL_EXAMPLE_DSN="$DATABASE_URL" go test ./025_prepared_statements

Needs a running TimescaleDB server: set DATABASE_URL, or pass -dsn, to the address of an empty database.

The files

Every file of the example, as it is in the Seagull repository.

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: tides
    dialect: postgres
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      driver: database/sql
      prepared: true
    migrate:
      dsn: ${env:DATABASE_URL}

db/migrations/0001_tide_times.up.sql

CREATE TABLE tide_times (
    id      BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    port    TEXT   NOT NULL,
    day     BIGINT NOT NULL,
    high_at TEXT   NOT NULL,
    UNIQUE (port, day)
);

db/migrations/0001_tide_times.down.sql

DROP TABLE tide_times;

db/queries/tide_times.sql

-- sgl.query(AddTideTime, exec)
INSERT INTO tide_times (port, day, high_at) VALUES ($1, $2, $3);

-- sgl.query(HighTide, one)
SELECT high_at FROM tide_times WHERE port = $1 AND day = $2;

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/postgres/025_prepared_statements/internal/store"
)

const (
	// days is how many days of tide times the example loads for each port.
	days = 3

	// minutesPerDay wraps tide times past midnight.
	minutesPerDay = 24 * 60

	// tideDrift is roughly how much later high tide comes each day, in minutes.
	tideDrift = 50
)

// ports lists the harbours and the minute of their first high tide.
var ports = []struct {
	name  string
	first int
}{
	{name: "Padstow", first: 5*60 + 12},
	{name: "Tenby", first: 6*60 + 40},
	{name: "Whitby", first: 11*60 + 3},
}

// run prepares every query once, loads a few days of tide times in a transaction, then
// looks up each port's high tide for each day through the prepared statements.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the tide table.
//
// Returns error when preparing or a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	// With go.prepared: true, sgl generates Prepare. It prepares every static query in the
	// package up front, so a query that no longer matches the schema fails here, at
	// start-up, rather than on first use. Close releases the statements. pgx's driver
	// already keeps a prepared statement per connection for each query it runs, so on
	// PostgreSQL that check at start-up is most of the gain.
	prepared, err := store.Prepare(ctx, database)
	if err != nil {
		return fmt.Errorf("preparing queries: %w", err)
	}
	defer func() { _ = prepared.Close() }()

	// *store.PreparedDBTX is a DBTX: every method of these Queries runs its prepared
	// statement instead of sending the SQL text again.
	queries := store.New(prepared)

	// RunInTx needs the *sql.DB itself, which UnderlyingDB returns. PreparedDBTX also has a
	// WrapDBTX method, so the Queries that RunInTx binds to the transaction keep using the
	// prepared statements, each bound to the *sql.Tx with StmtContext.
	err = queries.RunInTx(ctx, prepared.UnderlyingDB(), func(inTransaction *store.Queries) error {
		return loadTideTimes(ctx, inTransaction)
	})
	if err != nil {
		return fmt.Errorf("loading tide times: %w", err)
	}

	fmt.Fprint(output, "         ")
	for day := 1; day <= days; day++ {
		fmt.Fprintf(output, "  day %d", day)
	}
	fmt.Fprintln(output)

	// The same HighTide statement runs once per port and day.
	for _, port := range ports {
		fmt.Fprintf(output, "%-9s", port.name)
		for day := int64(1); day <= days; day++ {
			tide, err := queries.HighTide(ctx, store.HighTideParams{Port: port.name, Day: day})
			if err != nil {
				return fmt.Errorf("looking up %s on day %d: %w", port.name, day, err)
			}
			fmt.Fprintf(output, "  %s", tide.HighAt)
		}
		fmt.Fprintln(output)
	}
	return nil
}

// loadTideTimes adds a high tide for every port on every day, each a little later than
// the day before.
//
// Takes queries (*store.Queries) which add the rows.
//
// Returns error when a row cannot be added.
func loadTideTimes(ctx context.Context, queries *store.Queries) error {
	for _, port := range ports {
		for day := range days {
			minute := (port.first + day*tideDrift) % minutesPerDay
			err := queries.AddTideTime(ctx, store.AddTideTimeParams{
				Port:   port.name,
				Day:    int64(day + 1),
				HighAt: fmt.Sprintf("%02d:%02d", minute/60, minute%60),
			})
			if err != nil {
				return err
			}
		}
	}
	return nil
}

main.go

// Command 025_prepared_statements applies its migrations to a PostgreSQL database, then
// runs the example in example.go: every query is prepared once with the generated
// Prepare, and the tide-table lookups reuse the prepared statements.
//
// Run it with `go run . -dsn postgres://user:password@localhost:5432/example`, or set
// DATABASE_URL instead of passing -dsn. Give it an empty database of its own, as the
// migrations create its tables and the example adds rows each time it runs.
package main

import (
	"context"
	"database/sql"
	"embed"
	"errors"
	"flag"
	"fmt"
	"log"
	"os"

	_ "github.com/jackc/pgx/v5/stdlib"

	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_postgres"
)

// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS

func main() {
	dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "PostgreSQL connection string; defaults to $DATABASE_URL")
	flag.Parse()
	if err := execute(context.Background(), *dsn); err != nil {
		log.Fatal(err)
	}
}

// execute opens and migrates the database at dsn, and runs the example against it.
//
// Takes dsn (string) which is the PostgreSQL connection string.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, dsn string) error {
	if dsn == "" {
		return errors.New("set -dsn or DATABASE_URL to a PostgreSQL connection string")
	}
	database, err := sql.Open("pgx", dsn)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()

	migrator, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), migrations, "db/migrations")
	if err != nil {
		return err
	}
	if _, err := migrator.Up(ctx); err != nil {
		return fmt.Errorf("migrating: %w", err)
	}
	return run(ctx, database, os.Stdout)
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type TideTimes struct {
	ID     int64  `json:"id"`
	Port   string `json:"port"`
	Day    int64  `json:"day"`
	HighAt string `json:"high_at"`
}

internal/store/prepared.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
)

type PreparedDBTX struct {
	db    *sql.DB
	stmts map[string]*sql.Stmt
	mu    sync.RWMutex
}

func Prepare(ctx context.Context, db *sql.DB) (*PreparedDBTX, error) {
	staticQueries := []string{addTideTimeSQL, highTideSQL}
	stmts := make(map[string]*sql.Stmt, len(staticQueries))
	for _, query := range staticQueries {
		statement, err := db.PrepareContext(ctx, query)
		if err != nil {
			return nil, errors.Join(fmt.Errorf("preparing statement: %w", err), sglCloseStatements(stmts))
		}
		stmts[query] = statement
	}
	return &PreparedDBTX{db: db, stmts: stmts, mu: sync.RWMutex{}}, nil
}

func (prepared *PreparedDBTX) cachedStatement(query string) *sql.Stmt {
	prepared.mu.RLock()
	defer prepared.mu.RUnlock()
	return prepared.stmts[query]
}

func (prepared *PreparedDBTX) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.ExecContext(ctx, query, arguments...)
	}
	return statement.ExecContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.QueryContext(ctx, query, arguments...)
	}
	return statement.QueryContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	statement := prepared.cachedStatement(query)
	if statement == nil {
		return prepared.db.QueryRowContext(ctx, query, arguments...)
	}
	return statement.QueryRowContext(ctx, arguments...)
}

func (prepared *PreparedDBTX) BeginTx(ctx context.Context, options *sql.TxOptions) (*sql.Tx, error) {
	return prepared.db.BeginTx(ctx, options)
}

func (prepared *PreparedDBTX) PingContext(ctx context.Context) error {
	return prepared.db.PingContext(ctx)
}

func (prepared *PreparedDBTX) UnderlyingDB() *sql.DB {
	return prepared.db
}

func (prepared *PreparedDBTX) Close() error {
	prepared.mu.Lock()
	defer prepared.mu.Unlock()
	err := sglCloseStatements(prepared.stmts)
	prepared.stmts = make(map[string]*sql.Stmt)
	return err
}

func (prepared *PreparedDBTX) WrapDBTX(inner any) any {
	transaction, ok := inner.(*sql.Tx)
	if !ok {
		return inner
	}
	return &preparedTx{source: prepared, transaction: transaction}
}

type preparedTx struct {
	source      *PreparedDBTX
	transaction *sql.Tx
}

func (bound *preparedTx) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.ExecContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).ExecContext(ctx, arguments...)
}

func (bound *preparedTx) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.QueryContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).QueryContext(ctx, arguments...)
}

func (bound *preparedTx) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	statement := bound.source.cachedStatement(query)
	if statement == nil {
		return bound.transaction.QueryRowContext(ctx, query, arguments...)
	}
	return bound.transaction.StmtContext(ctx, statement).QueryRowContext(ctx, arguments...)
}

func sglCloseStatements(stmts map[string]*sql.Stmt) error {
	var closeErrors []error
	for _, statement := range stmts {
		if err := statement.Close(); err != nil {
			closeErrors = append(closeErrors, err)
		}
	}
	return errors.Join(closeErrors...)
}

internal/store/querier.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

func New(db DBTX) *Queries {
	wrapper, _ := db.(dbtxWrapper)
	return &Queries{reader: db, writer: db, wrapper: wrapper}
}

func NewWithReplica(writer DBTX, reader DBTX) *Queries {
	wrapper, _ := writer.(dbtxWrapper)
	return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

func (queries *Queries) wrapDBTX(db DBTX) DBTX {
	if queries.wrapper == nil {
		return db
	}
	wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
	if !ok {
		return db
	}
	return wrapped
}

func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
	transaction, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer transaction.Rollback()
	if err = fn(queries.WithTx(transaction)); err != nil {
		return err
	}
	return transaction.Commit()
}

internal/store/tide_times.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const addTideTimeSQL = `INSERT INTO tide_times (port, day, high_at) VALUES ($1, $2, $3);`

type AddTideTimeParams struct {
	Port   string
	Day    int64
	HighAt string
}

func (queries *Queries) AddTideTime(ctx context.Context, params AddTideTimeParams) error {
	_, err := queries.writer.ExecContext(ctx, addTideTimeSQL, params.Port, params.Day, params.HighAt)
	return err
}

const highTideSQL = `SELECT high_at FROM tide_times WHERE port = $1 AND day = $2;`

type HighTideParams struct {
	Port string
	Day  int64
}

type HighTideRow struct {
	HighAt string `json:"high_at"`
}

func (queries *Queries) HighTide(ctx context.Context, params HighTideParams) (HighTideRow, error) {
	var row HighTideRow
	err := queries.reader.QueryRowContext(ctx, highTideSQL, params.Port, params.Day).Scan(&row.HighAt)
	if err != nil {
		return HighTideRow{}, err
	}
	return row, nil
}

output.txt

           day 1  day 2  day 3
Padstow    05:12  06:02  06:52
Tenby      06:40  07:30  08:20
Whitby     11:03  11:53  12:43

Also on other engines