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.
| DuckDB | MySQL | |
|---|---|---|
section parameter | Section | MenuItemsSection |
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