Enums as generated Go types, and your own types with go_type

On this page

On PostgreSQL:

  • CREATE TYPE ... AS ENUM declares the labels, and the server refuses any other value
  • The enum becomes a named string type in models.go, named after the enum type (store.BoatStatus), with a constant per label and a Valid method, used in models.go, Params and Row structs
  • sgl.column(status, go_type: ...) makes one query return harbour.Status; parameters stay store.BoatStatus

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_DSN="$DATABASE_URL" go test ./007_enums

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

Also on other engines