GoSMig: a tiny, type-safe way to build your own SQL database migration CLI in Go


If you’ve ever wanted a migration tool that feels like Go — small, explicit, type-safe, and database-agnostic — GoSMig hits a sweet spot. It doesn’t ship a full-featured binary. Instead, it gives you a minimal, well-typed core you can embed to build your own migration CLI with almost no boilerplate.

Repo: https://github.com/padurean/gosmig

Note: GoSMig is a library, not a binary. You write a 30–60 line main() to define migrations and wire in your DB connection, and GoSMig handles the CLI parsing and command behavior for you.

Why another migrations tool?

I wanted something that:

GoSMig is basically “bring your own CLI wrapper,” with a dead-simple API that stays out of your way.

TL;DR install

go get github.com/padurean/gosmig

Go 1.25+ recommended.

A 60-second example (database/sql)

Create a tiny main.go that defines migrations and a DB connector. You’ll end up with a custom binary like ./migrate.

package main

import (
  "context"
  "database/sql"
  "fmt"
  "log"
  "time"

  _ "github.com/jackc/pgx/v5/stdlib"
  "github.com/padurean/gosmig"
)

func main() {
  // Define migrations (mix transactional and non-transactional as needed)
  migs := []gosmig.MigrationSQL{
    {
      Version: 1,
      UpDown: &gosmig.UpDownSQL{ // runs inside a transaction
        Up: func(ctx context.Context, tx *sql.Tx) error {
          _, err := tx.ExecContext(ctx, `
            CREATE TABLE teams (
             id SERIAL PRIMARY KEY,
             name TEXT NOT NULL UNIQUE,
             created_at TIMESTAMPTZ DEFAULT NOW()
            )`)
          return err
        },
        Down: func(ctx context.Context, tx *sql.Tx) error {
          _, err := tx.ExecContext(ctx, `DROP TABLE teams`)
          return err
        },
      },
    },
    {
      Version: 2,
      UpDownNoTX: &gosmig.UpDownNoTXSQL{ // runs without a transaction
        Up: func(ctx context.Context, db *sql.DB) error {
          _, err := db.ExecContext(ctx, `
            CREATE INDEX CONCURRENTLY idx_teams_created_at ON teams (created_at)`)
          return err
        },
        Down: func(ctx context.Context, db *sql.DB) error {
          _, err := db.ExecContext(ctx, `
            DROP INDEX CONCURRENTLY IF EXISTS idx_teams_created_at`)
          return err
        },
      },
    },
  }

  // Build a runner that parses args and executes commands
  run, err := gosmig.New(migs, connect, &gosmig.Config{Timeout: 30 * time.Second})
  if err != nil {
    log.Fatalf("failed to build migrator: %v", err)
  }
  run() // handles: <db_url> <up|up-one|down|status|version>
}

func connect(url string, timeout time.Duration) (*sql.DB, error) {
  db, err := sql.Open("pgx", url)
  if err != nil {
    return nil, fmt.Errorf("open DB: %w", err)
  }
  ctx, cancel := context.WithTimeout(context.Background(), timeout)
  defer cancel()
  if err := db.PingContext(ctx); err != nil {
    return nil, fmt.Errorf("ping DB: %w", err)
  }
  return db, nil
}

Run it:

go run . postgres://user:pass@localhost:5432/dbname?sslmode=disable up

Using sqlx (optional)

Prefer sqlx? Swap the DB type and define aliases to keep signatures tidy:

package main

import (
  "context"
  "database/sql"
  "log"
  "time"

  _ "github.com/jackc/pgx/v5/stdlib"
  "github.com/jmoiron/sqlx"
  "github.com/padurean/gosmig"
)

type (
  MigrationSQLX  = gosmig.Migration[*sql.Row, sql.Result, *sql.Tx, *sql.TxOptions, *sqlx.DB]
  UpDownNoTXSQLX = gosmig.UpDown[*sql.Row, sql.Result, *sqlx.DB]
)

func main() {
  migs := []MigrationSQLX{
    {
      Version: 1,
      UpDown: &gosmig.UpDownSQL{ /* ...same as stdlib example... */ },
    },
    {
      Version: 2,
      UpDownNoTX: &UpDownNoTXSQLX{
        Up:   func(ctx context.Context, db *sqlx.DB) error { /* ... */ return nil },
        Down: func(ctx context.Context, db *sqlx.DB) error { /* ... */ return nil },
      },
    },
  }

  run, err := gosmig.New(migs, connectSQLX, nil)
  if err != nil {
    log.Fatal(err)
  }
  run()
}

func connectSQLX(url string, timeout time.Duration) (*sqlx.DB, error) {
  db, err := sqlx.Open("pgx", url)
  if err != nil {
    return nil, err
  }
  ctx, cancel := context.WithTimeout(context.Background(), timeout)
  defer cancel()
  return db, db.PingContext(ctx)
}

Tip: The provided gosmig.UpDownSQL works for both database/sql and sqlx because it operates on *sql.Tx.

CLI commands you get “for free”

Once you wire up main(), GoSMig handles argument parsing and runs the command:

Examples:

Internals in a nutshell

GoSMig uses tiny, generic interfaces so anything that looks like database/sql can plug in:

There are two migration styles:

Migrations are validated at startup (unique, positive versions; both Up and Down present). A lightweight gosmig table tracks applied versions:

CREATE TABLE gosmig (
  version INTEGER PRIMARY KEY,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Operations use a timeout (default 10s; configurable via &gosmig.Config{Timeout: ...}), and GoSMig defends against concurrent version jumps during a migration with clear error messages.

Concurrency: keep it single-writer

Like any migration system, don’t run multiple writers at once. Use your DB’s advisory lock (or equivalent):

There’s a PostgreSQL example linked from the repo’s examples branch.

Tested: CI, coverage, and Docker

Some handy commands:

# run tests locally
make test

# spin up Dockerized Postgres and run tests
make test-docker

# lint and vulnerability checks
make lint
make vulncheck

# build (with checks) or build only
make build
make build-only

When to use GoSMig

Use it if you:

If you want a standalone, batteries-included CLI, you can still build one easily with GoSMig — but you’ll own the wrapper (which is usually a small and healthy amount of code).

Links

If you try GoSMig, I’d love your feedback — PRs and issues are welcome.

Twitter Facebook LinkedIn Copy Link
#Go #CLI #database #SQL #migrations