Skip to content

Connect a database

Two modules do this job. gas/database owns the connection pool and hands out gas.DatabaseProvider. gas/migrate applies schema changes that individual services declare. They are separate because plenty of apps want a database without migrations, and workers want migrations without a router.

Terminal window
go get github.com/gasmod/gas/database github.com/gasmod/gas/migrate
main.go
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Both are registered under their provider interface, because that is
// what services (and migrate itself) inject.
gas.WithSingletonService[gas.DatabaseProvider](database.New()),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
)

Settings bind from the config provider, so a DSN can come from DATABASE__DSN in the environment. To set them in code instead:

func explicitConfig() *database.Config {
// Start from DefaultConfig so Mode and the pool settings are populated. A
// bare &database.Config{} literal leaves Mode empty and fails Validate.
cfg := database.DefaultConfig()
cfg.Database.DSN = "postgres://user:pass@localhost:5432/mydb?sslmode=disable"
cfg.Database.Driver = "pgx"
return cfg
}
Mode Backend Use it when
ModeSQL (default) database/sql Any driver: PostgreSQL, SQLite, MySQL
ModePgx pgxpool.Pool PostgreSQL, and you want pgx types, batching, or the performance

DB() returns a *sql.DB in both modes, so sqlc’s database/sql output always works. In pgx mode, Pool() additionally returns the native pool for sqlc’s pgx output, and database.PoolFrom(provider) unwraps it, reporting false when the provider is not pgx-backed so you can fall back.

WithTx commits when the function returns nil and rolls back otherwise, including on panic.

// WithTx commits when fn returns nil and rolls back otherwise, including on
// panic. A rollback that itself fails is joined onto the returned error.
func createUser(ctx context.Context, db gas.DatabaseProvider) error {
return db.WithTx(ctx, nil, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, "INSERT INTO users (email) VALUES ($1)", "a@example.com"); err != nil {
return err
}
_, err := tx.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES (currval('users_id_seq'))")
return err
})
}

If the function returns an error, that error comes back and the rollback is attempted; a rollback that itself fails is logged and joined onto it. A commit failure is returned wrapped. In pgx mode, WithPgxTx mirrors this against pgx.Tx, rolling back on a context detached from cancellation so cleanup still runs when the caller’s context is already done.

A service declares its own schema during Init. Nothing central lists every migration, so deleting a service deletes its migrations with it.

notes/service.go
// Services register their own migrations during Init, so a service owns its
// schema and migrate applies everything in global version order at startup.
type Service struct {
migrations gas.MigrationManager
}
func (s *Service) Name() string { return "notes" }
func (s *Service) Init() error {
s.migrations.Register(s.Name(), gas.Migration{
Version: "20250216001",
Description: "create notes table",
Up: "CREATE TABLE notes (id SERIAL PRIMARY KEY, body TEXT NOT NULL);",
Down: "DROP TABLE notes;",
})
return nil
}
func (s *Service) Close() error { return nil }

RegisterSlice takes many at once, and RegisterFS reads .up.sql/.down.sql pairs out of an embed.FS using the naming convention {version}_{description}.up.sql.

Run (or Worker.Start) applies everything pending in global version order across all services, after services initialize and before traffic is accepted. Each migration runs in its own transaction.

Roll back with Down(n), which reverses the last n applied migrations in reverse version order.

sqlc generates a DBTX interface in its own output package, satisfied by *sql.DB and *sql.Tx in database/sql mode and by *pgxpool.Pool and pgx.Tx in pgx mode. Pass DB(), Pool(), or a transaction straight into the generated constructor. This module deliberately declares no DBTX of its own.

CheckHealth (liveness) fails only when the service is uninitialized or closed. It does not ping, because both database/sql and pgxpool reconnect on their own and a restart would not help a transient outage. CheckReady does ping, so a failing dependency drains traffic off the instance instead of killing it.

Field Default Description
Database.Mode sql sql or pgx
Database.Driver postgres database/sql driver name, ModeSQL only
Database.DSN Connection string, required unless a connector is supplied
Database.MaxOpenConns 25 Maximum open connections
Database.MaxIdleConns 5 Maximum idle connections, ModeSQL only
Database.ConnMaxLifetime 30m Maximum connection reuse time
Database.ConnMaxIdleTime 5m Maximum connection idle time
Database.ConnRetries 0 Connection retry attempts
Database.ConnRetryInterval 2s Base retry interval, doubling each attempt

For full control over connection setup, such as custom TLS or IAM auth tokens, pass a driver.Connector with database.WithConnector. Driver and DSN are then not required. Connectors are ModeSQL only.