Enums as generated Go types, and your own types with go_type
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.
An enum column accepts only the labels it declares, and the database refuses anything else. Go has no enum type for Seagull to map that to, so by default an enum column arrives as a plain string: correct, but it says nothing about which strings are allowed. Seagull does not invent enum types of its own. You write the Go type you want, with a constant per label and any methods you like, and point the column at it with a go_type override. The generated code then hands you that type instead of a string.
This example gives a harbour's boats a status: moored, at sea or in dry dock.
The schema
CREATE TYPE ... AS ENUM declares the labels once, as a type of its own, and the status column uses it.
db/migrations/0001_boats.up.sql
CREATE TYPE boat_status AS ENUM ('moored', 'at_sea', 'dry_dock');
CREATE TABLE boats (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
status boat_status NOT NULL DEFAULT 'moored'
);
As a string
ListBoats selects the column as it is, so Status is a string. It is a string in models.go and in every Params struct too.
internal/store
type ListBoatsRow struct {
Name string `json:"name"`
Status BoatStatus `json:"status"`
}
type BoatStatus string
func (queries *Queries) ListBoats(ctx context.Context) ([]ListBoatsRow, error) {
rows, err := queries.reader.QueryContext(ctx, listBoatsSQL)
if err != nil {
return nil, err
}
defer rows.Close()
var results []ListBoatsRow
for rows.Next() {
var row ListBoatsRow
if err := rows.Scan(&row.Name, &row.Status); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
As your own type
The program's own package, internal/harbour, declares type Status string with a constant for each label (Moored, AtSea and DryDock) and a Describe method. BoatsWithStatus names it with an sgl.column directive in its header, giving the type's full import path:
db/queries/boats.sql
-- sgl.query(BoatsWithStatus, many)
-- sgl.column(status, go_type: "seagull.sh/seagull/examples/postgres/007_enums/internal/harbour.Status")
SELECT name, status FROM boats WHERE status = $1 ORDER BY id;
Its rows now carry a harbour.Status, and the generated file imports the package for you:
internal/store
type BoatStatus string
type BoatsWithStatusRow struct {
Name string `json:"name"`
Status harbour.Status `json:"status"`
}
func (queries *Queries) BoatsWithStatus(ctx context.Context, status BoatStatus) ([]BoatsWithStatusRow, error) {
rows, err := queries.reader.QueryContext(ctx, boatsWithStatusSQL, status)
if err != nil {
return nil, err
}
defer rows.Close()
var results []BoatsWithStatusRow
for rows.Next() {
var row BoatsWithStatusRow
if err := rows.Scan(&row.Name, &row.Status); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
The directive applies to this one query. A types.overrides entry for boats.status in sgl.yaml would apply it to every query that returns the column as it is.
Parameters stay strings
An override changes the columns a query returns, not what it takes.
models.go and every Params field keep string, and so does the status that BoatsWithStatus filters on, so the program converts on the way in: string(harbour.AtSea).
The database still checks
A Go type with constants does not stop a caller passing any string it likes, but the enum does: the database refuses a label it does not list when the statement runs. The program tries it on purpose and prints the refusal.
Using it
example.go
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"seagull.sh/seagull/examples/postgres/007_enums/internal/store"
)
// run gives the harbour's boats a status from the boat_status enum and lists them twice.
// The enum becomes a named Go type in the generated package: models.go declares
// store.BoatStatus, named after the enum type, with a constant for each label, such as
// store.BoatStatusMoored, and a Valid method. Every column and parameter of the enum
// takes it, in models.go, in Params structs and in Row structs, as ListBoats shows. Valid
// reports whether a value is one of the labels, and the server still enforces them, so a
// status outside the enum is refused when the statement runs.
//
// For a type of your own, here harbour.Status with a method that describes each label,
// name it with a go_type override. BoatsWithStatus does that for one query with
// sgl.column(status, go_type: ...); a types.overrides entry in sgl.yaml, naming the enum
// type boat_status or the column boats.status, would do it for every query, and for the
// parameters bound to the column too. A query's sgl.column override changes only that
// query's returned column, so AddBoat, SetStatus and BoatsWithStatus's own parameter
// still take the generated store.BoatStatus.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives both listings.
//
// Returns error when a query fails or a status outside the enum is accepted.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
boats := []store.AddBoatParams{
{Name: "Kittiwake", Status: store.BoatStatusMoored},
{Name: "Salty Chip", Status: store.BoatStatusAtSea},
{Name: "Gull Wing", Status: store.BoatStatusDryDock},
{Name: "Driftwood", Status: store.BoatStatusMoored},
}
for _, boat := range boats {
if err := queries.AddBoat(ctx, boat); err != nil {
return fmt.Errorf("adding %s: %w", boat.Name, err)
}
}
sunk := store.BoatStatus("sunk")
if err := queries.AddBoat(ctx, store.AddBoatParams{Name: "Wreck", Status: sunk}); err == nil {
return errors.New("adding a boat with status sunk: got no error, want the enum to refuse it")
}
fmt.Fprintf(output, "status %q: Valid() is %t, and the server refuses it, as it is not a boat_status label\n", sunk, sunk.Valid())
if _, err := queries.SetStatus(ctx, store.SetStatusParams{Status: store.BoatStatusAtSea, Name: "Kittiwake"}); err != nil {
return fmt.Errorf("sending Kittiwake to sea: %w", err)
}
listed, err := queries.ListBoats(ctx)
if err != nil {
return fmt.Errorf("listing boats: %w", err)
}
fmt.Fprintln(output, "\nListBoats, where status is the generated store.BoatStatus:")
for _, boat := range listed {
fmt.Fprintf(output, " %-10s %-8s %T\n", boat.Name, boat.Status, boat.Status)
}
atSea, err := queries.BoatsWithStatus(ctx, store.BoatStatusAtSea)
if err != nil {
return fmt.Errorf("listing boats at sea: %w", err)
}
fmt.Fprintln(output, "\nBoatsWithStatus, where a go_type override makes status a harbour.Status:")
for _, boat := range atSea {
fmt.Fprintf(output, " %-10s %-14s %T\n", boat.Name, boat.Status.Describe(), boat.Status)
}
return nil
}
What it prints
status "sunk": Valid() is false, and the server refuses it, as it is not a boat_status label
ListBoats, where status is the generated store.BoatStatus:
Kittiwake at_sea store.BoatStatus
Salty Chip at_sea store.BoatStatus
Gull Wing dry_dock store.BoatStatus
Driftwood moored store.BoatStatus
BoatsWithStatus, where a go_type override makes status a harbour.Status:
Kittiwake out at sea harbour.Status
Salty Chip out at sea harbour.Status
Type overrides covers go_type and the other override forms in more depth.
Run it locally
From the directory you cloned the Seagull repository into:
cd seagull/examples/postgres && go run ./007_enums
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 ./007_enums
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: harbour
dialect: postgres
migrations: db/migrations
queries: db/queries
go:
package: store
out: internal/store
driver: database/sql
db/migrations/0001_boats.up.sql
CREATE TYPE boat_status AS ENUM ('moored', 'at_sea', 'dry_dock');
CREATE TABLE boats (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
status boat_status NOT NULL DEFAULT 'moored'
);
db/migrations/0001_boats.down.sql
DROP TABLE boats;
DROP TYPE boat_status;
db/queries/boats.sql
-- sgl.query(AddBoat, exec)
INSERT INTO boats (name, status) VALUES ($1, $2);
-- sgl.query(SetStatus, execrows)
UPDATE boats SET status = $1 WHERE name = $2;
-- sgl.query(ListBoats, many)
SELECT name, status FROM boats ORDER BY id;
-- sgl.query(BoatsWithStatus, many)
-- sgl.column(status, go_type: "seagull.sh/seagull/examples/postgres/007_enums/internal/harbour.Status")
SELECT name, status FROM boats WHERE status = $1 ORDER BY id;
example.go
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"seagull.sh/seagull/examples/postgres/007_enums/internal/store"
)
// run gives the harbour's boats a status from the boat_status enum and lists them twice.
// The enum becomes a named Go type in the generated package: models.go declares
// store.BoatStatus, named after the enum type, with a constant for each label, such as
// store.BoatStatusMoored, and a Valid method. Every column and parameter of the enum
// takes it, in models.go, in Params structs and in Row structs, as ListBoats shows. Valid
// reports whether a value is one of the labels, and the server still enforces them, so a
// status outside the enum is refused when the statement runs.
//
// For a type of your own, here harbour.Status with a method that describes each label,
// name it with a go_type override. BoatsWithStatus does that for one query with
// sgl.column(status, go_type: ...); a types.overrides entry in sgl.yaml, naming the enum
// type boat_status or the column boats.status, would do it for every query, and for the
// parameters bound to the column too. A query's sgl.column override changes only that
// query's returned column, so AddBoat, SetStatus and BoatsWithStatus's own parameter
// still take the generated store.BoatStatus.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives both listings.
//
// Returns error when a query fails or a status outside the enum is accepted.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
queries := store.New(database)
boats := []store.AddBoatParams{
{Name: "Kittiwake", Status: store.BoatStatusMoored},
{Name: "Salty Chip", Status: store.BoatStatusAtSea},
{Name: "Gull Wing", Status: store.BoatStatusDryDock},
{Name: "Driftwood", Status: store.BoatStatusMoored},
}
for _, boat := range boats {
if err := queries.AddBoat(ctx, boat); err != nil {
return fmt.Errorf("adding %s: %w", boat.Name, err)
}
}
sunk := store.BoatStatus("sunk")
if err := queries.AddBoat(ctx, store.AddBoatParams{Name: "Wreck", Status: sunk}); err == nil {
return errors.New("adding a boat with status sunk: got no error, want the enum to refuse it")
}
fmt.Fprintf(output, "status %q: Valid() is %t, and the server refuses it, as it is not a boat_status label\n", sunk, sunk.Valid())
if _, err := queries.SetStatus(ctx, store.SetStatusParams{Status: store.BoatStatusAtSea, Name: "Kittiwake"}); err != nil {
return fmt.Errorf("sending Kittiwake to sea: %w", err)
}
listed, err := queries.ListBoats(ctx)
if err != nil {
return fmt.Errorf("listing boats: %w", err)
}
fmt.Fprintln(output, "\nListBoats, where status is the generated store.BoatStatus:")
for _, boat := range listed {
fmt.Fprintf(output, " %-10s %-8s %T\n", boat.Name, boat.Status, boat.Status)
}
atSea, err := queries.BoatsWithStatus(ctx, store.BoatStatusAtSea)
if err != nil {
return fmt.Errorf("listing boats at sea: %w", err)
}
fmt.Fprintln(output, "\nBoatsWithStatus, where a go_type override makes status a harbour.Status:")
for _, boat := range atSea {
fmt.Fprintf(output, " %-10s %-14s %T\n", boat.Name, boat.Status.Describe(), boat.Status)
}
return nil
}
internal/harbour/harbour.go
// Package harbour holds the harbour's own types. BoatsWithStatus's go_type override
// returns Status in place of the enum type Seagull generates by default.
package harbour
// Status is where a boat is. Its values are the labels of the boat_status enum in
// db/migrations, which the server enforces.
type Status string
const (
// Moored marks a boat tied up in the harbour.
Moored Status = "moored"
// AtSea marks a boat out of the harbour.
AtSea Status = "at_sea"
// DryDock marks a boat out of the water for repair.
DryDock Status = "dry_dock"
)
// Describe returns the status in words, for printing.
//
// Returns string which describes where the boat is.
func (s Status) Describe() string {
switch s {
case Moored:
return "in the harbour"
case AtSea:
return "out at sea"
case DryDock:
return "in dry dock"
default:
return "somewhere unknown"
}
}
main.go
// Command 007_enums applies its migrations to a PostgreSQL database, then runs the
// example in example.go: boats are given statuses from a PostgreSQL enum and listed,
// first as the enum type Seagull generates and then as the harbour's own harbour.Status.
//
// 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/boats.sql.go
// Code generated by sgl. DO NOT EDIT.
package store
import (
"context"
"seagull.sh/seagull/examples/postgres/007_enums/internal/harbour"
)
const addBoatSQL = `INSERT INTO boats (name, status) VALUES ($1, $2);`
type AddBoatParams struct {
Name string
Status BoatStatus
}
func (queries *Queries) AddBoat(ctx context.Context, params AddBoatParams) error {
_, err := queries.writer.ExecContext(ctx, addBoatSQL, params.Name, params.Status)
return err
}
const boatsWithStatusSQL = `SELECT name, status FROM boats WHERE status = $1 ORDER BY id;`
type BoatsWithStatusRow struct {
Name string `json:"name"`
Status harbour.Status `json:"status"`
}
func (queries *Queries) BoatsWithStatus(ctx context.Context, status BoatStatus) ([]BoatsWithStatusRow, error) {
rows, err := queries.reader.QueryContext(ctx, boatsWithStatusSQL, status)
if err != nil {
return nil, err
}
defer rows.Close()
var results []BoatsWithStatusRow
for rows.Next() {
var row BoatsWithStatusRow
if err := rows.Scan(&row.Name, &row.Status); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
const listBoatsSQL = `SELECT name, status FROM boats ORDER BY id;`
type ListBoatsRow struct {
Name string `json:"name"`
Status BoatStatus `json:"status"`
}
func (queries *Queries) ListBoats(ctx context.Context) ([]ListBoatsRow, error) {
rows, err := queries.reader.QueryContext(ctx, listBoatsSQL)
if err != nil {
return nil, err
}
defer rows.Close()
var results []ListBoatsRow
for rows.Next() {
var row ListBoatsRow
if err := rows.Scan(&row.Name, &row.Status); err != nil {
return nil, err
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
const setStatusSQL = `UPDATE boats SET status = $1 WHERE name = $2;`
type SetStatusParams struct {
Status BoatStatus
Name string
}
func (queries *Queries) SetStatus(ctx context.Context, params SetStatusParams) (int64, error) {
results, err := queries.writer.ExecContext(ctx, setStatusSQL, params.Status, params.Name)
if err != nil {
return 0, err
}
return results.RowsAffected()
}
internal/store/models.go
// Code generated by sgl. DO NOT EDIT.
package store
type Boats struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status BoatStatus `json:"status"`
}
type BoatStatus string
const (
BoatStatusMoored BoatStatus = "moored"
BoatStatusAtSea BoatStatus = "at_sea"
BoatStatusDryDock BoatStatus = "dry_dock"
)
// Valid reports whether the value is one the enum declares.
func (value BoatStatus) Valid() bool {
switch value {
case BoatStatusMoored, BoatStatusAtSea, BoatStatusDryDock:
return true
}
return false
}
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
status "sunk": Valid() is false, and the server refuses it, as it is not a boat_status label
ListBoats, where status is the generated store.BoatStatus:
Kittiwake at_sea store.BoatStatus
Salty Chip at_sea store.BoatStatus
Gull Wing dry_dock store.BoatStatus
Driftwood moored store.BoatStatus
BoatsWithStatus, where a go_type override makes status a harbour.Status:
Kittiwake out at sea harbour.Status
Salty Chip out at sea harbour.Status