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

On this page

On DuckDB:

  • CREATE TYPE ... AS ENUM declares the enum; ORDER BY sorts its values in declaration order
  • Each enum becomes a named string type in models.go, named after the enum type, with a constant per value (store.SectionFish) and a Valid method
  • A types.overrides entry with sql: naming the enum type gives every column of that enum, and the parameters bound to them, your own type, and the generated package declares none for it
  • sgl.column(section, go_type: ...) changes only the column ItemsInSection returns; its parameter stays the generated store.Section
  • DuckDB refuses a value the enum does not list, with an error naming its UINT8 storage type

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 is a chip shop menu. Each item has a section and a status, and both are enums.

The schema

CREATE TYPE ... AS ENUM declares each enum once, as a type of its own, and the columns use them.

db/migrations/0001_menu.up.sql

CREATE TYPE section AS ENUM ('fish', 'sides', 'puddings');

CREATE TYPE item_status AS ENUM ('available', 'sold_out');

CREATE SEQUENCE menu_item_ids;

CREATE TABLE menu_items (
    id      BIGINT      PRIMARY KEY DEFAULT nextval('menu_item_ids'),
    name    VARCHAR     NOT NULL UNIQUE,
    section section     NOT NULL,
    status  item_status NOT NULL DEFAULT 'available'
);

Your own types

The program's own package, internal/menu, declares type Section string and type Status string, with a constant for each label: Fish, Sides and Puddings, then Available and SoldOut. The example points the columns at them in the two places an override can live.

For every query, sgl.yaml names the column in types.overrides. Any query that returns menu_items.status as it is gets a menu.Status:

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: menu
    dialect: duckdb
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      types:
        overrides:
          - sql: item_status
            go: seagull.sh/seagull/examples/duckdb/007_enums/internal/menu.Status

For one query, an sgl.column directive in the query's header does the same, here for section:

db/queries/menu.sql

-- sgl.query(ItemsInSection, many)
-- sgl.column(section, go_type: "seagull.sh/seagull/examples/duckdb/007_enums/internal/menu.Section")
SELECT name, section, status FROM menu_items WHERE section = $1 ORDER BY name;

So the two queries differ. ListMenu has no directive, which leaves Section a plain string, while its Status is typed by sgl.yaml:

internal/store

type ListMenuRow struct {
	Name    string      `json:"name"`
	Section Section     `json:"section"`
	Status  menu.Status `json:"status"`
}

type Section string

func (queries *Queries) ListMenu(ctx context.Context) ([]ListMenuRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listMenuSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListMenuRow
	for rows.Next() {
		var row ListMenuRow
		if err := rows.Scan(&row.Name, &row.Section, &row.Status); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

ItemsInSection has both, and the generated file imports the package for you:

internal/store

type ItemsInSectionRow struct {
	Name    string       `json:"name"`
	Section menu.Section `json:"section"`
	Status  menu.Status  `json:"status"`
}

type Section string

func (queries *Queries) ItemsInSection(ctx context.Context, section Section) ([]ItemsInSectionRow, error) {
	rows, err := queries.reader.QueryContext(ctx, itemsInSectionSQL, section)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ItemsInSectionRow
	for rows.Next() {
		var row ItemsInSectionRow
		if err := rows.Scan(&row.Name, &row.Section, &row.Status); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

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 section that ItemsInSection filters on, so the program converts on the way in: string(menu.Fish).

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/duckdb/007_enums/internal/menu"
	"seagull.sh/seagull/examples/duckdb/007_enums/internal/store"
)

// run fills a chip shop menu whose section and status columns are DuckDB enums, marks an
// item sold out and reads the menu back. Each enum becomes a named Go type in the
// generated package unless an override names a type of your own:
//
//   - section has no override, so models.go declares store.Section, with a constant for
//     each value, such as store.SectionFish, and a Valid method; ListMenuRow.Section,
//     AddItemParams.Section and ItemsInSection's parameter are all a store.Section;
//   - sgl.yaml's types.overrides names the enum type item_status, so every column of that
//     enum gives menu.Status, as in ListMenuRow.Status, and so does every parameter
//     compared with or assigned to one, as in SetStatusParams.Status; the generated
//     package then declares no type for item_status;
//   - sgl.column(section, go_type: ...) in ItemsInSection gives that one query's returned
//     Section the type menu.Section; the parameter it compares with stays a
//     store.Section.
//
// An enum sorts in the order its values are declared, not alphabetically, so ORDER BY
// section lists fish, then sides, then puddings. Valid reports whether a value is one the
// enum declares, so the shop can check a section before it binds it. DuckDB checks each
// value against the enum as it is stored as well, so a section the enum does not list is
// refused; DuckDB keeps an enum as a small integer, and its error names that type.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the menu, the fish section and the refusal.
//
// Returns error when a query fails other than as the example expects.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	items := []store.AddItemParams{
		{Name: "cod", Section: store.SectionFish},
		{Name: "haddock", Section: store.SectionFish},
		{Name: "chips", Section: store.SectionSides},
		{Name: "mushy peas", Section: store.SectionSides},
		{Name: "sticky toffee pudding", Section: store.SectionPuddings},
	}
	for _, item := range items {
		if err := queries.AddItem(ctx, item); err != nil {
			return fmt.Errorf("adding %s: %w", item.Name, err)
		}
	}
	if _, err := queries.SetStatus(ctx, store.SetStatusParams{Status: menu.SoldOut, Name: "haddock"}); err != nil {
		return fmt.Errorf("selling out of haddock: %w", err)
	}

	listed, err := queries.ListMenu(ctx)
	if err != nil {
		return fmt.Errorf("listing the menu: %w", err)
	}
	var menuRow store.ListMenuRow
	fmt.Fprintf(output, "ListMenu: section %T, status %T\n", menuRow.Section, menuRow.Status)
	for _, item := range listed {
		fmt.Fprintf(output, "  %-9s %s%s\n", item.Section, item.Name, soldOut(item.Status))
	}

	fish, err := queries.ItemsInSection(ctx, store.SectionFish)
	if err != nil {
		return fmt.Errorf("listing the fish: %w", err)
	}
	var sectionRow store.ItemsInSectionRow
	fmt.Fprintf(output, "ItemsInSection: section %T, status %T\n", sectionRow.Section, sectionRow.Status)
	for _, item := range fish {
		fmt.Fprintf(output, "  %-9s %s%s\n", item.Section, item.Name, soldOut(item.Status))
	}

	drinks := store.Section("drinks")
	fmt.Fprintf(output, "section drinks is valid: %t\n", drinks.Valid())
	err = queries.AddItem(ctx, store.AddItemParams{Name: "lemonade", Section: drinks})
	if err == nil {
		return errors.New("added an item to a section the enum does not list")
	}
	fmt.Fprintf(output, "refused section drinks: %v\n", err)
	return nil
}

// soldOut returns a note for an item that has sold out, comparing against the typed
// constant rather than a string.
//
// Takes status (menu.Status) which is the item's status.
//
// Returns string which is the note, or empty when the item is available.
func soldOut(status menu.Status) string {
	if status == menu.SoldOut {
		return " (sold out)"
	}
	return ""
}

What it prints

ListMenu: section store.Section, status menu.Status
  fish      cod
  fish      haddock (sold out)
  sides     chips
  sides     mushy peas
  puddings  sticky toffee pudding
ItemsInSection: section menu.Section, status menu.Status
  fish      cod
  fish      haddock (sold out)
section drinks is valid: false
refused section drinks: Conversion Error: Could not convert string 'drinks' to UINT8

Type overrides covers go_type and the other override forms in more depth.

Across engines

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

DuckDBMySQL
section parameterSectionMenuItemsSection

MariaDB runs the MySQL version unchanged.

Compare the whole example side by side with MySQL.

Where the example is missing:

  • Not on SQLite. SQLite has no enum type. Use a CHECK constraint and a go_type override.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/duckdb && go run ./007_enums

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

cd seagull/examples/duckdb && go test ./007_enums

Needs cgo and a C compiler (the DuckDB driver).

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: menu
    dialect: duckdb
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      types:
        overrides:
          - sql: item_status
            go: seagull.sh/seagull/examples/duckdb/007_enums/internal/menu.Status

db/migrations/0001_menu.up.sql

CREATE TYPE section AS ENUM ('fish', 'sides', 'puddings');

CREATE TYPE item_status AS ENUM ('available', 'sold_out');

CREATE SEQUENCE menu_item_ids;

CREATE TABLE menu_items (
    id      BIGINT      PRIMARY KEY DEFAULT nextval('menu_item_ids'),
    name    VARCHAR     NOT NULL UNIQUE,
    section section     NOT NULL,
    status  item_status NOT NULL DEFAULT 'available'
);

db/migrations/0001_menu.down.sql

DROP TABLE menu_items;
DROP SEQUENCE menu_item_ids;
DROP TYPE item_status;
DROP TYPE section;

db/queries/menu.sql

-- sgl.query(AddItem, exec)
INSERT INTO menu_items (name, section) VALUES ($1, $2);

-- sgl.query(SetStatus, execrows)
UPDATE menu_items SET status = $1 WHERE name = $2;

-- sgl.query(ListMenu, many)
SELECT name, section, status FROM menu_items ORDER BY section, name;

-- sgl.query(ItemsInSection, many)
-- sgl.column(section, go_type: "seagull.sh/seagull/examples/duckdb/007_enums/internal/menu.Section")
SELECT name, section, status FROM menu_items WHERE section = $1 ORDER BY name;

example.go

package main

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

	"seagull.sh/seagull/examples/duckdb/007_enums/internal/menu"
	"seagull.sh/seagull/examples/duckdb/007_enums/internal/store"
)

// run fills a chip shop menu whose section and status columns are DuckDB enums, marks an
// item sold out and reads the menu back. Each enum becomes a named Go type in the
// generated package unless an override names a type of your own:
//
//   - section has no override, so models.go declares store.Section, with a constant for
//     each value, such as store.SectionFish, and a Valid method; ListMenuRow.Section,
//     AddItemParams.Section and ItemsInSection's parameter are all a store.Section;
//   - sgl.yaml's types.overrides names the enum type item_status, so every column of that
//     enum gives menu.Status, as in ListMenuRow.Status, and so does every parameter
//     compared with or assigned to one, as in SetStatusParams.Status; the generated
//     package then declares no type for item_status;
//   - sgl.column(section, go_type: ...) in ItemsInSection gives that one query's returned
//     Section the type menu.Section; the parameter it compares with stays a
//     store.Section.
//
// An enum sorts in the order its values are declared, not alphabetically, so ORDER BY
// section lists fish, then sides, then puddings. Valid reports whether a value is one the
// enum declares, so the shop can check a section before it binds it. DuckDB checks each
// value against the enum as it is stored as well, so a section the enum does not list is
// refused; DuckDB keeps an enum as a small integer, and its error names that type.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the menu, the fish section and the refusal.
//
// Returns error when a query fails other than as the example expects.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(database)

	items := []store.AddItemParams{
		{Name: "cod", Section: store.SectionFish},
		{Name: "haddock", Section: store.SectionFish},
		{Name: "chips", Section: store.SectionSides},
		{Name: "mushy peas", Section: store.SectionSides},
		{Name: "sticky toffee pudding", Section: store.SectionPuddings},
	}
	for _, item := range items {
		if err := queries.AddItem(ctx, item); err != nil {
			return fmt.Errorf("adding %s: %w", item.Name, err)
		}
	}
	if _, err := queries.SetStatus(ctx, store.SetStatusParams{Status: menu.SoldOut, Name: "haddock"}); err != nil {
		return fmt.Errorf("selling out of haddock: %w", err)
	}

	listed, err := queries.ListMenu(ctx)
	if err != nil {
		return fmt.Errorf("listing the menu: %w", err)
	}
	var menuRow store.ListMenuRow
	fmt.Fprintf(output, "ListMenu: section %T, status %T\n", menuRow.Section, menuRow.Status)
	for _, item := range listed {
		fmt.Fprintf(output, "  %-9s %s%s\n", item.Section, item.Name, soldOut(item.Status))
	}

	fish, err := queries.ItemsInSection(ctx, store.SectionFish)
	if err != nil {
		return fmt.Errorf("listing the fish: %w", err)
	}
	var sectionRow store.ItemsInSectionRow
	fmt.Fprintf(output, "ItemsInSection: section %T, status %T\n", sectionRow.Section, sectionRow.Status)
	for _, item := range fish {
		fmt.Fprintf(output, "  %-9s %s%s\n", item.Section, item.Name, soldOut(item.Status))
	}

	drinks := store.Section("drinks")
	fmt.Fprintf(output, "section drinks is valid: %t\n", drinks.Valid())
	err = queries.AddItem(ctx, store.AddItemParams{Name: "lemonade", Section: drinks})
	if err == nil {
		return errors.New("added an item to a section the enum does not list")
	}
	fmt.Fprintf(output, "refused section drinks: %v\n", err)
	return nil
}

// soldOut returns a note for an item that has sold out, comparing against the typed
// constant rather than a string.
//
// Takes status (menu.Status) which is the item's status.
//
// Returns string which is the note, or empty when the item is available.
func soldOut(status menu.Status) string {
	if status == menu.SoldOut {
		return " (sold out)"
	}
	return ""
}

internal/menu/menu.go

// Package menu holds the chip shop's own types. The generated store uses them in place of
// the enum types it would declare, through the override in sgl.yaml and the queries.
package menu

// Section is the part of the menu an item is listed in, matching the section enum.
type Section string

const (
	// Fish lists the fried fish.
	Fish Section = "fish"

	// Sides lists chips, peas and sauces.
	Sides Section = "sides"

	// Puddings lists the desserts.
	Puddings Section = "puddings"
)

// Status is whether a menu item can be ordered, matching the item_status enum.
type Status string

const (
	// Available marks an item that can be ordered.
	Available Status = "available"

	// SoldOut marks an item that has run out for the day.
	SoldOut Status = "sold_out"
)

main.go

// Command 007_enums applies its migrations to an in-memory DuckDB database, then runs the
// example in example.go: a chip shop menu with two enum columns is read back as the enum
// types Seagull generates and as the shop's own Go types.
//
// Run it with `go run .`, or `go run . -db example.duckdb` to keep the database in a
// file.
package main

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

	_ "github.com/duckdb/duckdb-go/v2"

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

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

func main() {
	path := flag.String("db", "", "DuckDB database file; an in-memory database when empty")
	flag.Parse()
	if err := execute(context.Background(), *path); err != nil {
		log.Fatal(err)
	}
}

// execute opens and migrates the DuckDB database at path, or an in-memory one when path
// is empty, and runs the example against it.
//
// Takes path (string) which is the database file, or empty.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, path string) error {
	database, err := sql.Open("duckdb", path)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()

	migrator, err := migrate.NewMigrator(database, migrate_backend_duckdb.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/menu.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"

	"seagull.sh/seagull/examples/duckdb/007_enums/internal/menu"
)

const addItemSQL = `INSERT INTO menu_items (name, section) VALUES ($1, $2);`

type AddItemParams struct {
	Name    string
	Section Section
}

func (queries *Queries) AddItem(ctx context.Context, params AddItemParams) error {
	_, err := queries.writer.ExecContext(ctx, addItemSQL, params.Name, params.Section)
	return err
}

const itemsInSectionSQL = `SELECT name, section, status FROM menu_items WHERE section = $1 ORDER BY name;`

type ItemsInSectionRow struct {
	Name    string       `json:"name"`
	Section menu.Section `json:"section"`
	Status  menu.Status  `json:"status"`
}

func (queries *Queries) ItemsInSection(ctx context.Context, section Section) ([]ItemsInSectionRow, error) {
	rows, err := queries.reader.QueryContext(ctx, itemsInSectionSQL, section)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ItemsInSectionRow
	for rows.Next() {
		var row ItemsInSectionRow
		if err := rows.Scan(&row.Name, &row.Section, &row.Status); err != nil {
			return nil, err
		}
		results = append(results, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

const listMenuSQL = `SELECT name, section, status FROM menu_items ORDER BY section, name;`

type ListMenuRow struct {
	Name    string      `json:"name"`
	Section Section     `json:"section"`
	Status  menu.Status `json:"status"`
}

func (queries *Queries) ListMenu(ctx context.Context) ([]ListMenuRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listMenuSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListMenuRow
	for rows.Next() {
		var row ListMenuRow
		if err := rows.Scan(&row.Name, &row.Section, &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 menu_items SET status = $1 WHERE name = $2;`

type SetStatusParams struct {
	Status menu.Status
	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

import "seagull.sh/seagull/examples/duckdb/007_enums/internal/menu"

type MenuItems struct {
	ID      int64       `json:"id"`
	Name    string      `json:"name"`
	Section Section     `json:"section"`
	Status  menu.Status `json:"status"`
}

type Section string

const (
	SectionFish     Section = "fish"
	SectionSides    Section = "sides"
	SectionPuddings Section = "puddings"
)

// Valid reports whether the value is one the enum declares.
func (value Section) Valid() bool {
	switch value {
	case SectionFish, SectionSides, SectionPuddings:
		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

ListMenu: section store.Section, status menu.Status
  fish      cod
  fish      haddock (sold out)
  sides     chips
  sides     mushy peas
  puddings  sticky toffee pudding
ItemsInSection: section menu.Section, status menu.Status
  fish      cod
  fish      haddock (sold out)
section drinks is valid: false
refused section drinks: Conversion Error: Could not convert string 'drinks' to UINT8

Also on other engines