From SQL types to Go types

On this page

On PostgreSQL:

  • $1 placeholders
  • Every integer type maps by its width: SMALLINT, INTEGER and BIGINT give int16, int32 and int64
  • REAL is single precision and maps to float32; DOUBLE PRECISION maps to float64
  • NUMERIC maps to apd.Decimal and UUID to uuid.UUID, so example.go imports both packages
  • DATE and TIMESTAMPTZ map to time.Time; TIMESTAMPTZ arrives in the connection's time zone
  • INET has no mapping and arrives as any, which pgx's database/sql driver fills with a string

Seagull reads each column's declared type from your migrations and picks the Go type that holds it without loss. Integers keep the width and sign the engine stores, floats their precision, exact decimals become a decimal type rather than a float, dates and times become time.Time, and a column that can be NULL becomes a pointer. You never choose a Go type for a column by hand, and you never scan a NUMERIC into a float64 by accident.

This example records one landing of fish, with a column of each common kind, reads it back, and prints the Go type every column became.

The schema

db/migrations/0001_landings.up.sql

CREATE TABLE landings (
    id         BIGINT           GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    catch_id   UUID             NOT NULL,
    species    TEXT             NOT NULL,
    boxes      SMALLINT         NOT NULL,
    fish       INTEGER          NOT NULL,
    weight_g   BIGINT           NOT NULL,
    length_cm  REAL             NOT NULL,
    sea_temp   DOUBLE PRECISION NOT NULL,
    price      NUMERIC(8, 2)    NOT NULL,
    on_ice     BOOLEAN          NOT NULL,
    landed_on  DATE             NOT NULL,
    landed_at  TIMESTAMPTZ      NOT NULL,
    harbour_ip INET             NOT NULL,
    tag        BYTEA            NOT NULL,
    notes      TEXT
);

What sgl generates

The row type for GetLanding is the mapping, field by field:

internal/store

type GetLandingRow struct {
	ID        int64       `json:"id"`
	CatchID   uuid.UUID   `json:"catch_id"`
	Species   string      `json:"species"`
	Boxes     int16       `json:"boxes"`
	Fish      int32       `json:"fish"`
	WeightG   int64       `json:"weight_g"`
	LengthCm  float32     `json:"length_cm"`
	SeaTemp   float64     `json:"sea_temp"`
	Price     apd.Decimal `json:"price"`
	OnIce     bool        `json:"on_ice"`
	LandedOn  time.Time   `json:"landed_on"`
	LandedAt  time.Time   `json:"landed_at"`
	HarbourIP any         `json:"harbour_ip"`
	Tag       []byte      `json:"tag"`
	Notes     *string     `json:"notes"`
}

func (queries *Queries) GetLanding(ctx context.Context, id int64) (GetLandingRow, error) {
	var row GetLandingRow
	err := queries.reader.QueryRowContext(ctx, getLandingSQL, id).Scan(&row.ID, &row.CatchID, &row.Species, &row.Boxes, &row.Fish, &row.WeightG, &row.LengthCm, &row.SeaTemp, &row.Price, &row.OnIce, &row.LandedOn, &row.LandedAt, &row.HarbourIP, &row.Tag, &row.Notes)
	if err != nil {
		return GetLandingRow{}, err
	}
	return row, nil
}
  • SMALLINT, INTEGER and BIGINT give int16, int32 and int64.
  • REAL is single precision, so it gives float32; DOUBLE PRECISION gives float64.
  • NUMERIC gives apd.Decimal from github.com/cockroachdb/apd/v3, which keeps money exact: the price comes back as 412.80, with its trailing zero.
  • UUID gives uuid.UUID from github.com/google/uuid.
  • DATE and TIMESTAMPTZ give time.Time. A TIMESTAMPTZ arrives in the connection's time zone, so the program prints it in UTC.
  • BYTEA gives []byte, and the nullable notes gives *string.
  • INET is a type Seagull has no mapping for, so HarbourIP is any, and the driver decides what it holds: pgx's database/sql driver hands back a string. A go_type override pins a column like this to a type of your choosing.

Parameters follow the same rules, so RecordLanding takes the same Go types it hands back.

Using it

The program records a landing and reads it back, then prints each column's name, the Go type of its field (with %T) and its value. %T prints []byte as []uint8, since byte is an alias for uint8.

example.go

package main

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

	"github.com/cockroachdb/apd/v3"
	"github.com/google/uuid"

	"seagull.sh/seagull/examples/postgres/005_type_mapping/internal/store"
)

// run records a landing of fish and reads it back, printing the Go type sgl chose for
// each column of db/migrations. PostgreSQL's types are exact, so each maps by name:
//
//   - SMALLINT, INTEGER and BIGINT give int16, int32 and int64;
//   - REAL is single precision and gives float32, while DOUBLE PRECISION gives float64;
//   - NUMERIC gives apd.Decimal from github.com/cockroachdb/apd/v3, which keeps money
//     exact, and UUID gives uuid.UUID from github.com/google/uuid;
//   - DATE and TIMESTAMPTZ give time.Time, and BYTEA gives []byte;
//   - a type Seagull has no mapping for, such as INET, gives any, and the driver decides
//     what it holds: pgx's database/sql driver hands back the address as a string. Give
//     such a column a go_type override to pin it down.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives one line per column.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	recorded, err := queries.RecordLanding(ctx, store.RecordLandingParams{
		CatchID:   uuid.MustParse("6f1c0b8e-4b2a-4c55-9d7e-2a3f5e8c1d40"),
		Species:   "mackerel",
		Boxes:     12,
		Fish:      480,
		WeightG:   163_200,
		LengthCm:  31.5,
		SeaTemp:   12.25,
		Price:     *apd.New(41_280, -2),
		OnIce:     true,
		LandedOn:  time.Date(2026, time.May, 1, 0, 0, 0, 0, time.UTC),
		LandedAt:  time.Date(2026, time.May, 1, 5, 30, 0, 0, time.UTC),
		HarbourIP: "192.0.2.17",
		Tag:       []byte{0x04, 0xa2, 0x19, 0xf3},
		Notes:     new("landed at Newlyn"),
	})
	if err != nil {
		return fmt.Errorf("recording a landing: %w", err)
	}

	landing, err := queries.GetLanding(ctx, recorded.ID)
	if err != nil {
		return fmt.Errorf("getting landing %d: %w", recorded.ID, err)
	}
	fmt.Fprintf(output, "id          %-12T %d\n", landing.ID, landing.ID)
	fmt.Fprintf(output, "catch_id    %-12T %s\n", landing.CatchID, landing.CatchID)
	fmt.Fprintf(output, "species     %-12T %s\n", landing.Species, landing.Species)
	fmt.Fprintf(output, "boxes       %-12T %d\n", landing.Boxes, landing.Boxes)
	fmt.Fprintf(output, "fish        %-12T %d\n", landing.Fish, landing.Fish)
	fmt.Fprintf(output, "weight_g    %-12T %d\n", landing.WeightG, landing.WeightG)
	fmt.Fprintf(output, "length_cm   %-12T %.1f\n", landing.LengthCm, landing.LengthCm)
	fmt.Fprintf(output, "sea_temp    %-12T %.2f\n", landing.SeaTemp, landing.SeaTemp)
	fmt.Fprintf(output, "price       %-12T %s\n", landing.Price, landing.Price.String())
	fmt.Fprintf(output, "on_ice      %-12T %t\n", landing.OnIce, landing.OnIce)
	fmt.Fprintf(output, "landed_on   %-12T %s\n", landing.LandedOn, landing.LandedOn.Format(time.DateOnly))
	// TIMESTAMPTZ arrives in the connection's time zone, so print it in UTC.
	fmt.Fprintf(output, "landed_at   %-12T %s\n", landing.LandedAt, landing.LandedAt.UTC().Format(time.RFC3339))
	fmt.Fprintf(output, "harbour_ip  %-12T %v\n", landing.HarbourIP, landing.HarbourIP)
	// %T prints []byte as []uint8, since byte is an alias for uint8.
	fmt.Fprintf(output, "tag         %-12T % x\n", landing.Tag, landing.Tag)
	fmt.Fprintf(output, "notes       %-12T %s\n", landing.Notes, *landing.Notes)
	return nil
}

What it prints

id          int64        1
catch_id    uuid.UUID    6f1c0b8e-4b2a-4c55-9d7e-2a3f5e8c1d40
species     string       mackerel
boxes       int16        12
fish        int32        480
weight_g    int64        163200
length_cm   float32      31.5
sea_temp    float64      12.25
price       apd.Decimal  412.80
on_ice      bool         true
landed_on   time.Time    2026-05-01
landed_at   time.Time    2026-05-01T05:30:00Z
harbour_ip  string       192.0.2.17
tag         []uint8      04 a2 19 f3
notes       *string      landed at Newlyn

Changing a type

The defaults are a starting point. A go_type directive on a column, or an override in sgl.yaml, swaps in a type of your own, such as a named Pence type for money; Type overrides shows how. The type mapping reference lists every default, and the order in which overrides apply.

With pgx

The landing read back through pgx, which decodes the same Row struct from PostgreSQL's binary formats, INTERVAL into time.Duration and an unmapped INET into a netip.Prefix.

What changes:

  • $1 placeholders
  • The Row and Params types are the same as database/sql's; pgx decodes the values itself
  • INTERVAL maps to time.Duration, which pgx decodes itself, counting a month as 30 days
  • INET has no mapping and arrives as any, which pgx fills with a netip.Prefix rather than a string
  • NUMERIC maps to apd.Decimal and UUID to uuid.UUID, which pgx fills through their Scan methods

db/queries/landings.sql

-- sgl.query(RecordLanding, one)
INSERT INTO landings (catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, at_sea, harbour_ip, tag, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id;

-- sgl.query(GetLanding, one)
SELECT id, catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, at_sea, harbour_ip, tag, notes
FROM landings
WHERE id = $1;

internal/store

type GetLandingRow struct {
	ID        int64         `json:"id"`
	CatchID   uuid.UUID     `json:"catch_id"`
	Species   string        `json:"species"`
	Boxes     int16         `json:"boxes"`
	Fish      int32         `json:"fish"`
	WeightG   int64         `json:"weight_g"`
	LengthCm  float32       `json:"length_cm"`
	SeaTemp   float64       `json:"sea_temp"`
	Price     apd.Decimal   `json:"price"`
	OnIce     bool          `json:"on_ice"`
	LandedOn  time.Time     `json:"landed_on"`
	LandedAt  time.Time     `json:"landed_at"`
	AtSea     time.Duration `json:"at_sea"`
	HarbourIP any           `json:"harbour_ip"`
	Tag       []byte        `json:"tag"`
	Notes     *string       `json:"notes"`
}

func (queries *Queries) GetLanding(ctx context.Context, id int64) (GetLandingRow, error) {
	var row GetLandingRow
	err := queries.db.QueryRow(ctx, getLandingSQL, id).Scan(&row.ID, &row.CatchID, &row.Species, &row.Boxes, &row.Fish, &row.WeightG, &row.LengthCm, &row.SeaTemp, &row.Price, &row.OnIce, &row.LandedOn, &row.LandedAt, &row.AtSea, &row.HarbourIP, &row.Tag, &row.Notes)
	if err != nil {
		return GetLandingRow{}, err
	}
	return row, nil
}

example.go

package main

import (
	"context"
	"fmt"
	"io"
	"time"

	"github.com/cockroachdb/apd/v3"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgxpool"

	"seagull.sh/seagull/examples/postgres/005_type_mapping_pgx/internal/store"
)

// run records a landing of fish and reads it back through pgx, printing the Go type sgl
// chose for each column of db/migrations. Most field types match the database/sql
// emitter's, but the native pgx emitter maps INTERVAL to time.Duration rather than
// string. pgx reads PostgreSQL's binary formats itself:
//
//   - SMALLINT, INTEGER and BIGINT give int16, int32 and int64; REAL gives float32 and
//     DOUBLE PRECISION gives float64;
//   - NUMERIC gives apd.Decimal and UUID gives uuid.UUID, which pgx fills through their
//     Scan methods;
//   - DATE and TIMESTAMPTZ give time.Time, and INTERVAL gives time.Duration, which pgx
//     decodes itself, counting a month as 30 days;
//   - INET has no mapping in Seagull and gives any, which pgx fills with a netip.Prefix,
//     where database/sql would give a string.
//
// Takes pool (*pgxpool.Pool) which is a pool on a migrated database.
// Takes output (io.Writer) which receives one line per column.
//
// Returns error when a query fails.
func run(ctx context.Context, pool *pgxpool.Pool, output io.Writer) error {
	queries := store.New(pool)

	recorded, err := queries.RecordLanding(ctx, store.RecordLandingParams{
		CatchID:   uuid.MustParse("6f1c0b8e-4b2a-4c55-9d7e-2a3f5e8c1d40"),
		Species:   "mackerel",
		Boxes:     12,
		Fish:      480,
		WeightG:   163_200,
		LengthCm:  31.5,
		SeaTemp:   12.25,
		Price:     *apd.New(41_280, -2),
		OnIce:     true,
		LandedOn:  time.Date(2026, time.May, 1, 0, 0, 0, 0, time.UTC),
		LandedAt:  time.Date(2026, time.May, 1, 5, 30, 0, 0, time.UTC),
		AtSea:     14*time.Hour + 45*time.Minute,
		HarbourIP: "192.0.2.17",
		Tag:       []byte{0x04, 0xa2, 0x19, 0xf3},
		Notes:     new("landed at Newlyn"),
	})
	if err != nil {
		return fmt.Errorf("recording a landing: %w", err)
	}

	landing, err := queries.GetLanding(ctx, recorded.ID)
	if err != nil {
		return fmt.Errorf("getting landing %d: %w", recorded.ID, err)
	}
	fmt.Fprintf(output, "id          %-14T %d\n", landing.ID, landing.ID)
	fmt.Fprintf(output, "catch_id    %-14T %s\n", landing.CatchID, landing.CatchID)
	fmt.Fprintf(output, "species     %-14T %s\n", landing.Species, landing.Species)
	fmt.Fprintf(output, "boxes       %-14T %d\n", landing.Boxes, landing.Boxes)
	fmt.Fprintf(output, "fish        %-14T %d\n", landing.Fish, landing.Fish)
	fmt.Fprintf(output, "weight_g    %-14T %d\n", landing.WeightG, landing.WeightG)
	fmt.Fprintf(output, "length_cm   %-14T %.1f\n", landing.LengthCm, landing.LengthCm)
	fmt.Fprintf(output, "sea_temp    %-14T %.2f\n", landing.SeaTemp, landing.SeaTemp)
	fmt.Fprintf(output, "price       %-14T %s\n", landing.Price, landing.Price.String())
	fmt.Fprintf(output, "on_ice      %-14T %t\n", landing.OnIce, landing.OnIce)
	fmt.Fprintf(output, "landed_on   %-14T %s\n", landing.LandedOn, landing.LandedOn.Format(time.DateOnly))
	// TIMESTAMPTZ arrives in the connection's time zone, so print it in UTC.
	fmt.Fprintf(output, "landed_at   %-14T %s\n", landing.LandedAt, landing.LandedAt.UTC().Format(time.RFC3339))
	fmt.Fprintf(output, "at_sea      %-14T %s\n", landing.AtSea, landing.AtSea)
	fmt.Fprintf(output, "harbour_ip  %-14T %v\n", landing.HarbourIP, landing.HarbourIP)
	// %T prints []byte as []uint8, since byte is an alias for uint8.
	fmt.Fprintf(output, "tag         %-14T % x\n", landing.Tag, landing.Tag)
	fmt.Fprintf(output, "notes       %-14T %s\n", landing.Notes, *landing.Notes)
	return nil
}

Across engines

Only these Go types of GetLanding differ between engines; the rest are the same everywhere.

PostgreSQLSQLiteMySQLDuckDBClickHouse
id parameterint64int64int64int64uint64
id columnint64int64int64int64uint64
boxes columnint16int64int16int16uint16
fish columnint32int64int32int32uint32
length_cm columnfloat32float64float64float64float32
tag column[]byte[]byte[]byte[]bytestring
catch_id columnuuid.UUID··uuid.UUIDuuid.UUID
sea_temp columnfloat64····
price columnapd.Decimal···decimal.Decimal
landed_on columntime.Time··time.Time·
harbour_ip columnany····
berth column··uint8··
price_per_kg column··apd.Decimal··
quay column···uint8·
water_temp column···float32·
auction_lot column···*big.Int·

A · marks a parameter or column that version of the example does not have.

TimescaleDB runs the PostgreSQL version unchanged.

MariaDB runs the MySQL version unchanged.

Compare the whole example side by side with SQLite, MySQL, DuckDB and ClickHouse.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/postgres && go run ./005_type_mapping

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

cd seagull/examples/postgres && SGL_EXAMPLE_DSN="$DATABASE_URL" go test ./005_type_mapping

Needs a running PostgreSQL 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: landings
    dialect: postgres
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      driver: database/sql

db/migrations/0001_landings.up.sql

CREATE TABLE landings (
    id         BIGINT           GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    catch_id   UUID             NOT NULL,
    species    TEXT             NOT NULL,
    boxes      SMALLINT         NOT NULL,
    fish       INTEGER          NOT NULL,
    weight_g   BIGINT           NOT NULL,
    length_cm  REAL             NOT NULL,
    sea_temp   DOUBLE PRECISION NOT NULL,
    price      NUMERIC(8, 2)    NOT NULL,
    on_ice     BOOLEAN          NOT NULL,
    landed_on  DATE             NOT NULL,
    landed_at  TIMESTAMPTZ      NOT NULL,
    harbour_ip INET             NOT NULL,
    tag        BYTEA            NOT NULL,
    notes      TEXT
);

db/migrations/0001_landings.down.sql

DROP TABLE landings;

db/queries/landings.sql

-- sgl.query(RecordLanding, one)
INSERT INTO landings (catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, harbour_ip, tag, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id;

-- sgl.query(GetLanding, one)
SELECT id, catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, harbour_ip, tag, notes
FROM landings
WHERE id = $1;

example.go

package main

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

	"github.com/cockroachdb/apd/v3"
	"github.com/google/uuid"

	"seagull.sh/seagull/examples/postgres/005_type_mapping/internal/store"
)

// run records a landing of fish and reads it back, printing the Go type sgl chose for
// each column of db/migrations. PostgreSQL's types are exact, so each maps by name:
//
//   - SMALLINT, INTEGER and BIGINT give int16, int32 and int64;
//   - REAL is single precision and gives float32, while DOUBLE PRECISION gives float64;
//   - NUMERIC gives apd.Decimal from github.com/cockroachdb/apd/v3, which keeps money
//     exact, and UUID gives uuid.UUID from github.com/google/uuid;
//   - DATE and TIMESTAMPTZ give time.Time, and BYTEA gives []byte;
//   - a type Seagull has no mapping for, such as INET, gives any, and the driver decides
//     what it holds: pgx's database/sql driver hands back the address as a string. Give
//     such a column a go_type override to pin it down.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives one line per column.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	recorded, err := queries.RecordLanding(ctx, store.RecordLandingParams{
		CatchID:   uuid.MustParse("6f1c0b8e-4b2a-4c55-9d7e-2a3f5e8c1d40"),
		Species:   "mackerel",
		Boxes:     12,
		Fish:      480,
		WeightG:   163_200,
		LengthCm:  31.5,
		SeaTemp:   12.25,
		Price:     *apd.New(41_280, -2),
		OnIce:     true,
		LandedOn:  time.Date(2026, time.May, 1, 0, 0, 0, 0, time.UTC),
		LandedAt:  time.Date(2026, time.May, 1, 5, 30, 0, 0, time.UTC),
		HarbourIP: "192.0.2.17",
		Tag:       []byte{0x04, 0xa2, 0x19, 0xf3},
		Notes:     new("landed at Newlyn"),
	})
	if err != nil {
		return fmt.Errorf("recording a landing: %w", err)
	}

	landing, err := queries.GetLanding(ctx, recorded.ID)
	if err != nil {
		return fmt.Errorf("getting landing %d: %w", recorded.ID, err)
	}
	fmt.Fprintf(output, "id          %-12T %d\n", landing.ID, landing.ID)
	fmt.Fprintf(output, "catch_id    %-12T %s\n", landing.CatchID, landing.CatchID)
	fmt.Fprintf(output, "species     %-12T %s\n", landing.Species, landing.Species)
	fmt.Fprintf(output, "boxes       %-12T %d\n", landing.Boxes, landing.Boxes)
	fmt.Fprintf(output, "fish        %-12T %d\n", landing.Fish, landing.Fish)
	fmt.Fprintf(output, "weight_g    %-12T %d\n", landing.WeightG, landing.WeightG)
	fmt.Fprintf(output, "length_cm   %-12T %.1f\n", landing.LengthCm, landing.LengthCm)
	fmt.Fprintf(output, "sea_temp    %-12T %.2f\n", landing.SeaTemp, landing.SeaTemp)
	fmt.Fprintf(output, "price       %-12T %s\n", landing.Price, landing.Price.String())
	fmt.Fprintf(output, "on_ice      %-12T %t\n", landing.OnIce, landing.OnIce)
	fmt.Fprintf(output, "landed_on   %-12T %s\n", landing.LandedOn, landing.LandedOn.Format(time.DateOnly))
	// TIMESTAMPTZ arrives in the connection's time zone, so print it in UTC.
	fmt.Fprintf(output, "landed_at   %-12T %s\n", landing.LandedAt, landing.LandedAt.UTC().Format(time.RFC3339))
	fmt.Fprintf(output, "harbour_ip  %-12T %v\n", landing.HarbourIP, landing.HarbourIP)
	// %T prints []byte as []uint8, since byte is an alias for uint8.
	fmt.Fprintf(output, "tag         %-12T % x\n", landing.Tag, landing.Tag)
	fmt.Fprintf(output, "notes       %-12T %s\n", landing.Notes, *landing.Notes)
	return nil
}

main.go

// Command 005_type_mapping applies its migrations to a PostgreSQL database, then runs the
// example in example.go: one landing of fish is recorded and read back, printing the Go
// type of each column.
//
// 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/landings.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"time"

	"github.com/cockroachdb/apd/v3"
	"github.com/google/uuid"
)

const getLandingSQL = `SELECT id, catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, harbour_ip, tag, notes
FROM landings
WHERE id = $1;`

type GetLandingRow struct {
	ID        int64       `json:"id"`
	CatchID   uuid.UUID   `json:"catch_id"`
	Species   string      `json:"species"`
	Boxes     int16       `json:"boxes"`
	Fish      int32       `json:"fish"`
	WeightG   int64       `json:"weight_g"`
	LengthCm  float32     `json:"length_cm"`
	SeaTemp   float64     `json:"sea_temp"`
	Price     apd.Decimal `json:"price"`
	OnIce     bool        `json:"on_ice"`
	LandedOn  time.Time   `json:"landed_on"`
	LandedAt  time.Time   `json:"landed_at"`
	HarbourIP any         `json:"harbour_ip"`
	Tag       []byte      `json:"tag"`
	Notes     *string     `json:"notes"`
}

func (queries *Queries) GetLanding(ctx context.Context, id int64) (GetLandingRow, error) {
	var row GetLandingRow
	err := queries.reader.QueryRowContext(ctx, getLandingSQL, id).Scan(&row.ID, &row.CatchID, &row.Species, &row.Boxes, &row.Fish, &row.WeightG, &row.LengthCm, &row.SeaTemp, &row.Price, &row.OnIce, &row.LandedOn, &row.LandedAt, &row.HarbourIP, &row.Tag, &row.Notes)
	if err != nil {
		return GetLandingRow{}, err
	}
	return row, nil
}

const recordLandingSQL = `INSERT INTO landings (catch_id, species, boxes, fish, weight_g, length_cm, sea_temp, price, on_ice, landed_on, landed_at, harbour_ip, tag, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id;`

type RecordLandingParams struct {
	CatchID   uuid.UUID
	Species   string
	Boxes     int16
	Fish      int32
	WeightG   int64
	LengthCm  float32
	SeaTemp   float64
	Price     apd.Decimal
	OnIce     bool
	LandedOn  time.Time
	LandedAt  time.Time
	HarbourIP any
	Tag       []byte
	Notes     *string
}

type RecordLandingRow struct {
	ID int64 `json:"id"`
}

func (queries *Queries) RecordLanding(ctx context.Context, params RecordLandingParams) (RecordLandingRow, error) {
	var row RecordLandingRow
	err := queries.writer.QueryRowContext(ctx, recordLandingSQL, params.CatchID, params.Species, params.Boxes, params.Fish, params.WeightG, params.LengthCm, params.SeaTemp, params.Price, params.OnIce, params.LandedOn, params.LandedAt, params.HarbourIP, params.Tag, params.Notes).Scan(&row.ID)
	if err != nil {
		return RecordLandingRow{}, err
	}
	return row, nil
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"time"

	"github.com/cockroachdb/apd/v3"
	"github.com/google/uuid"
)

type Landings struct {
	ID        int64       `json:"id"`
	CatchID   uuid.UUID   `json:"catch_id"`
	Species   string      `json:"species"`
	Boxes     int16       `json:"boxes"`
	Fish      int32       `json:"fish"`
	WeightG   int64       `json:"weight_g"`
	LengthCm  float32     `json:"length_cm"`
	SeaTemp   float64     `json:"sea_temp"`
	Price     apd.Decimal `json:"price"`
	OnIce     bool        `json:"on_ice"`
	LandedOn  time.Time   `json:"landed_on"`
	LandedAt  time.Time   `json:"landed_at"`
	HarbourIP any         `json:"harbour_ip"`
	Tag       []byte      `json:"tag"`
	Notes     *string     `json:"notes"`
}

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()
}

output.txt

id          int64        1
catch_id    uuid.UUID    6f1c0b8e-4b2a-4c55-9d7e-2a3f5e8c1d40
species     string       mackerel
boxes       int16        12
fish        int32        480
weight_g    int64        163200
length_cm   float32      31.5
sea_temp    float64      12.25
price       apd.Decimal  412.80
on_ice      bool         true
landed_on   time.Time    2026-05-01
landed_at   time.Time    2026-05-01T05:30:00Z
harbour_ip  string       192.0.2.17
tag         []uint8      04 a2 19 f3
notes       *string      landed at Newlyn

Also on other engines