Seeds, namespaces and hooks

On this page

On SQLite:

  • SQLite cannot change journal_mode inside a transaction, so that migration carries sgl.migration(no_transaction: true)
  • SQLite has no CREATE INDEX CONCURRENTLY; VACUUM is the statement Seagull detects as non-transactional without a directive
  • the seed file runs whole, since modernc.org/sqlite accepts several statements in one call

Beyond applying migrations, the migrator has a few features for real deployments. A namespace keeps one program's migration history apart from another's in the same database. Hooks run your own code before a run and after each migration. Some statements cannot run inside a transaction, and a directive tells the migrator so. A seeder applies data files once each, so reference data can ship with the program. The example sets up the menu of a chip shop with all four.

Namespaces

The migrator records what it has applied in a history table, and the seeder in a seed table. Both are named from a namespace, sgl by default. migrate.WithNamespace("chippy") renames them to chippy_migrations and chippy_seeds, so several programs, or several modules of one program, can each keep their own history in one database without seeing each other's.

sgl.yaml sets migrate.namespace to match, so sgl migrate and sgl seed on the command line use the same tables as the program:

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: chippy
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    seeds: db/seeds
    go:
      package: store
      out: internal/store
    migrate:
      namespace: chippy

To show the histories are separate, the program also reads the status of the same files against the default namespace's sgl_migrations, and finds nothing applied there.

Hooks

Hooks are options to NewMigrator. migrate.WithBeforeRun is called once before a run with the pending versions, and migrate.WithAfterMigration after each migration it applies. The example only prints from them, but a hook could record metrics, notify someone, or check a precondition: returning an error from a hook stops the run.

A migration outside a transaction

Each migration normally runs in a transaction, so it applies whole or not at all. Some statements refuse to run inside one.

SQLite will not change its journal mode inside a transaction, so the migration that switches to write-ahead logging carries sgl.migration(no_transaction: true):

db/migrations/0002_write_ahead_log.up.sql

-- sgl.migration(no_transaction: true)
PRAGMA journal_mode = WAL;

Seagull recognises some such statements by itself, such as VACUUM; PRAGMA journal_mode needs the directive. Its down file carries the directive too, since switching back has the same restriction.

Keep a statement like this in a migration of its own. If it shares a file with other statements, the whole file has to run without a transaction and is no longer atomic, and sgl vet warns with Q043. The migrations guide has more.

Seeds

Seeds are .sql files of data, in the directory seeds: names in sgl.yaml. migrate.NewSeeder is built like a migrator, and Apply runs each seed file that has not been applied, records it in the seed table, and returns how many it ran. Calling it at every start-up is safe: the second call finds nothing to do.

db/seeds/0001_menu.sql

INSERT INTO menu (dish, price_pence) VALUES
    ('cod and chips', 950),
    ('haddock and chips', 1020),
    ('mushy peas', 250)
ON CONFLICT (dish) DO NOTHING;

The file runs whole, since the driver accepts several statements in one call. It also says ON CONFLICT (dish) DO NOTHING, which keeps it harmless if the seed history is ever cleared and it runs again.

Using it

example.go

package main

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

	"seagull.sh/seagull/examples/sqlite/029_seeds_namespaces_hooks/internal/store"
	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
)

const (
	// namespace prefixes the tables that record this program's migrations and seeds.
	namespace = "chippy"
)

// run migrates the database under its own namespace with hooks that report progress,
// applies the seed files twice to show they run once, and prints the seeded menu.
//
// Takes database (*sql.DB) which is an empty database.
// Takes files (fs.FS) which holds db/migrations and db/seeds, the embed.FS in main.go.
// Takes output (io.Writer) which receives each step's result.
//
// Returns error when migrating, seeding or a query fails.
func run(ctx context.Context, database *sql.DB, files fs.FS, output io.Writer) error {
	// WithNamespace renames every table the migrator and seeder keep, so several programs or
	// modules can each keep their own history in one database. sgl.yaml sets
	// migrate.namespace to match, so `sgl migrate` uses the same tables.
	backend := migrate_backend_sqlite.New(migrate.WithNamespace(namespace))
	names := backend.Names()
	fmt.Fprintf(output, "history tables: %s and %s\n", names.History, names.Seeds)

	// Hooks run around the migrations: before the run with the pending versions, and after
	// each migration. Returning an error from a hook stops the run.
	migrator, err := migrate.NewMigrator(database, backend, files, "db/migrations",
		migrate.WithBeforeRun(func(_ context.Context, pending migrate.MigrationRunHookContext) error {
			fmt.Fprintf(output, "migrating: %d pending, versions %v\n", pending.PendingCount, pending.PendingVersions)
			return nil
		}),
		migrate.WithAfterMigration(func(_ context.Context, migration migrate.MigrationHookContext) error {
			fmt.Fprintf(output, "  applied %04d %s\n", migration.Version, migration.Name)
			return nil
		}),
	)
	if err != nil {
		return err
	}
	if _, err := migrator.Up(ctx); err != nil {
		return fmt.Errorf("migrating: %w", err)
	}

	// Migration 0002 ran outside a transaction, as its sgl.migration directive asks.
	var journalMode string
	if err := database.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
		return fmt.Errorf("reading the journal mode: %w", err)
	}
	fmt.Fprintf(output, "journal mode: %s\n", journalMode)

	// The default namespace is a separate history in the same database, and it has recorded
	// nothing.
	if err := printOtherHistory(ctx, database, files, output); err != nil {
		return err
	}

	// A Seeder applies each seed file once and records it in the seed table, so calling
	// Apply at every start-up is safe. The seed file also says ON CONFLICT DO NOTHING, which
	// keeps it harmless if the seed history is ever cleared.
	seeder, err := migrate.NewSeeder(database, backend, files, "db/seeds")
	if err != nil {
		return err
	}
	for range 2 {
		seeded, err := seeder.Apply(ctx)
		if err != nil {
			return fmt.Errorf("seeding: %w", err)
		}
		fmt.Fprintf(output, "seeds applied: %d\n", seeded)
	}

	menu, err := store.New(database).ListMenu(ctx)
	if err != nil {
		return fmt.Errorf("listing the menu: %w", err)
	}
	for _, dish := range menu {
		fmt.Fprintf(output, "  %-17s %dp\n", dish.Dish, dish.PricePence)
	}
	return nil
}

// printOtherHistory reads the same migration files against the default namespace's
// history, which this program never wrote to.
//
// Takes database (*sql.DB) which is the migrated database.
// Takes files (fs.FS) which holds the migration files.
// Takes output (io.Writer) which receives the count.
//
// Returns error when the status cannot be read.
func printOtherHistory(ctx context.Context, database *sql.DB, files fs.FS, output io.Writer) error {
	backend := migrate_backend_sqlite.New()
	migrator, err := migrate.NewMigrator(database, backend, files, "db/migrations")
	if err != nil {
		return err
	}
	statuses, err := migrator.Status(ctx)
	if err != nil {
		return fmt.Errorf("reading the default history: %w", err)
	}
	applied := 0
	for _, status := range statuses {
		if status.Applied {
			applied++
		}
	}
	fmt.Fprintf(output, "%s (default namespace): %d of %d migrations applied\n", backend.Names().History, applied, len(statuses))
	return nil
}

What it prints

The hooks report the two pending migrations and each one as it is applied, and the journal mode reads wal afterwards. The default namespace has recorded nothing, and the second Apply runs no seeds.

history tables: chippy_migrations and chippy_seeds
migrating: 2 pending, versions [1 2]
  applied 0001 menu
  applied 0002 write_ahead_log
journal mode: wal
sgl_migrations (default namespace): 0 of 2 migrations applied
seeds applied: 1
seeds applied: 0
  cod and chips     950p
  haddock and chips 1020p
  mushy peas        250p

Try it

Add a CREATE TABLE to the end of migration 0002 and run sgl vet. It reports Q043: the new table would be created outside a transaction too, and a failure between the two statements would leave the migration half-applied.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/sqlite && go run ./029_seeds_namespaces_hooks

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

cd seagull/examples/sqlite && go test ./029_seeds_namespaces_hooks

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: chippy
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    seeds: db/seeds
    go:
      package: store
      out: internal/store
    migrate:
      namespace: chippy

db/migrations/0001_menu.up.sql

CREATE TABLE menu (
    id          INTEGER PRIMARY KEY,
    dish        TEXT    NOT NULL UNIQUE,
    price_pence INTEGER NOT NULL
);

db/migrations/0002_write_ahead_log.up.sql

-- sgl.migration(no_transaction: true)
PRAGMA journal_mode = WAL;

db/migrations/0001_menu.down.sql

DROP TABLE menu;

db/migrations/0002_write_ahead_log.down.sql

-- sgl.migration(no_transaction: true)
PRAGMA journal_mode = DELETE;

db/queries/menu.sql

-- sgl.query(ListMenu, many)
SELECT dish, price_pence FROM menu ORDER BY dish;

db/seeds/0001_menu.sql

INSERT INTO menu (dish, price_pence) VALUES
    ('cod and chips', 950),
    ('haddock and chips', 1020),
    ('mushy peas', 250)
ON CONFLICT (dish) DO NOTHING;

example.go

package main

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

	"seagull.sh/seagull/examples/sqlite/029_seeds_namespaces_hooks/internal/store"
	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
)

const (
	// namespace prefixes the tables that record this program's migrations and seeds.
	namespace = "chippy"
)

// run migrates the database under its own namespace with hooks that report progress,
// applies the seed files twice to show they run once, and prints the seeded menu.
//
// Takes database (*sql.DB) which is an empty database.
// Takes files (fs.FS) which holds db/migrations and db/seeds, the embed.FS in main.go.
// Takes output (io.Writer) which receives each step's result.
//
// Returns error when migrating, seeding or a query fails.
func run(ctx context.Context, database *sql.DB, files fs.FS, output io.Writer) error {
	// WithNamespace renames every table the migrator and seeder keep, so several programs or
	// modules can each keep their own history in one database. sgl.yaml sets
	// migrate.namespace to match, so `sgl migrate` uses the same tables.
	backend := migrate_backend_sqlite.New(migrate.WithNamespace(namespace))
	names := backend.Names()
	fmt.Fprintf(output, "history tables: %s and %s\n", names.History, names.Seeds)

	// Hooks run around the migrations: before the run with the pending versions, and after
	// each migration. Returning an error from a hook stops the run.
	migrator, err := migrate.NewMigrator(database, backend, files, "db/migrations",
		migrate.WithBeforeRun(func(_ context.Context, pending migrate.MigrationRunHookContext) error {
			fmt.Fprintf(output, "migrating: %d pending, versions %v\n", pending.PendingCount, pending.PendingVersions)
			return nil
		}),
		migrate.WithAfterMigration(func(_ context.Context, migration migrate.MigrationHookContext) error {
			fmt.Fprintf(output, "  applied %04d %s\n", migration.Version, migration.Name)
			return nil
		}),
	)
	if err != nil {
		return err
	}
	if _, err := migrator.Up(ctx); err != nil {
		return fmt.Errorf("migrating: %w", err)
	}

	// Migration 0002 ran outside a transaction, as its sgl.migration directive asks.
	var journalMode string
	if err := database.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
		return fmt.Errorf("reading the journal mode: %w", err)
	}
	fmt.Fprintf(output, "journal mode: %s\n", journalMode)

	// The default namespace is a separate history in the same database, and it has recorded
	// nothing.
	if err := printOtherHistory(ctx, database, files, output); err != nil {
		return err
	}

	// A Seeder applies each seed file once and records it in the seed table, so calling
	// Apply at every start-up is safe. The seed file also says ON CONFLICT DO NOTHING, which
	// keeps it harmless if the seed history is ever cleared.
	seeder, err := migrate.NewSeeder(database, backend, files, "db/seeds")
	if err != nil {
		return err
	}
	for range 2 {
		seeded, err := seeder.Apply(ctx)
		if err != nil {
			return fmt.Errorf("seeding: %w", err)
		}
		fmt.Fprintf(output, "seeds applied: %d\n", seeded)
	}

	menu, err := store.New(database).ListMenu(ctx)
	if err != nil {
		return fmt.Errorf("listing the menu: %w", err)
	}
	for _, dish := range menu {
		fmt.Fprintf(output, "  %-17s %dp\n", dish.Dish, dish.PricePence)
	}
	return nil
}

// printOtherHistory reads the same migration files against the default namespace's
// history, which this program never wrote to.
//
// Takes database (*sql.DB) which is the migrated database.
// Takes files (fs.FS) which holds the migration files.
// Takes output (io.Writer) which receives the count.
//
// Returns error when the status cannot be read.
func printOtherHistory(ctx context.Context, database *sql.DB, files fs.FS, output io.Writer) error {
	backend := migrate_backend_sqlite.New()
	migrator, err := migrate.NewMigrator(database, backend, files, "db/migrations")
	if err != nil {
		return err
	}
	statuses, err := migrator.Status(ctx)
	if err != nil {
		return fmt.Errorf("reading the default history: %w", err)
	}
	applied := 0
	for _, status := range statuses {
		if status.Applied {
			applied++
		}
	}
	fmt.Fprintf(output, "%s (default namespace): %d of %d migrations applied\n", backend.Names().History, applied, len(statuses))
	return nil
}

main.go

// Command 029_seeds_namespaces_hooks opens a SQLite database and runs the example in
// example.go, which migrates it under its own namespace with progress hooks, including a
// migration that must run outside a transaction, and then applies the seed files.
//
// Run it with `go run .`, or `go run . -db chippy.db` to keep the database; a second run
// against the same file applies no migrations and no seeds.
package main

import (
	"context"
	"database/sql"
	"embed"
	"flag"
	"log"
	"os"
	"path/filepath"

	_ "modernc.org/sqlite"
)

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

func main() {
	path := flag.String("db", "", "SQLite database file; a temporary one when empty")
	flag.Parse()
	if err := execute(context.Background(), *path); err != nil {
		log.Fatal(err)
	}
}

// execute opens the database at path, or a temporary one when path is empty, and runs the
// example against it. The example migrates and seeds the database itself.
//
// Takes path (string) which is the database file, or empty.
//
// Returns error when the database cannot be opened or the example fails.
func execute(ctx context.Context, path string) error {
	if path == "" {
		directory, err := os.MkdirTemp("", "seagull-example-")
		if err != nil {
			return err
		}
		defer func() { _ = os.RemoveAll(directory) }()
		path = filepath.Join(directory, "example.db")
	}
	database, err := sql.Open("sqlite", "file:"+path)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()
	return run(ctx, database, files, os.Stdout)
}

internal/store/menu.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const listMenuSQL = `SELECT dish, price_pence FROM menu ORDER BY dish;`

type ListMenuRow struct {
	Dish       string `json:"dish"`
	PricePence int64  `json:"price_pence"`
}

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
	if rows.Next() {
		var row ListMenuRow
		scanArgs := []any{&row.Dish, &row.PricePence}
		for more := true; more; more = rows.Next() {
			row = ListMenuRow{}
			if err := rows.Scan(scanArgs...); err != nil {
				return nil, err
			}
			results = append(results, row)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Menu struct {
	ID         int64  `json:"id"`
	Dish       string `json:"dish"`
	PricePence int64  `json:"price_pence"`
}

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

history tables: chippy_migrations and chippy_seeds
migrating: 2 pending, versions [1 2]
  applied 0001 menu
  applied 0002 write_ahead_log
journal mode: wal
sgl_migrations (default namespace): 0 of 2 migrations applied
seeds applied: 1
seeds applied: 0
  cod and chips     950p
  haddock and chips 1020p
  mushy peas        250p

Also on other engines