sqltx

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2025 License: 0BSD Imports: 11 Imported by: 0

README

sqltx

Go Reference

Elegant, defer-based transaction management for your Go code

sqltx is a minimal Go library for reliable, database-agnostic transaction management. It builds on database/sql to unify transactions and savepoints, providing a consistent API that aims to work identically across PostgreSQL, MySQL, and SQLite with no dependencies and no surprises.

Why?

Go’s database/sql package offers powerful primitives, but handling nested transactions and savepoints reliably across different SQL dialects can be challenging. sqltx provides a database-agnostic abstraction layer for PostgreSQL, MySQL, and SQLite, exposing a unified API for safe, composable transaction management in Go.

More importantly, sqltx follows a defer-first philosophy: transactions are resources, not callbacks. You start one, defer its completion, and write code that flows top-to-bottom like any other Go function.

Design Philosophy

The Never-Nester Principle

Most transaction helpers in Go look like this:

err := RunTx(ctx, db, func(tx *sql.Tx) error {
    // your logic here
    return nil
})

That pattern hides control flow inside closures and makes nested operations clumsy.

sqltx takes the opposite approach. It embraces Go’s native defer mechanism to manage transactional scope:

tx, release, err := sqltx.Begin(ctx, db)
if err != nil {
    return err
}
defer release(&errOut)

Each transaction (or savepoint) becomes a resource with a deterministic lifetime, making your code explicit, composable, and easy to reason about — without callback pyramids or control-flow gymnastics.

Caveats and Dialect Differences

sqltx relies on standard SQL semantics for BEGIN, COMMIT, ROLLBACK, and SAVEPOINT. while the library behaves consistently across drivers, the underlying databases implement these features with subtle differences worth being aware of:

Database Behavior Notes
PostgreSQL ✅ Full support for nested transactions using named savepoints. Considered the reference implementation. All operations are atomic and reversible.
MySQL / MariaDB ⚠️ Partial support. Only the InnoDB engine supports SAVEPOINT. DDL statements may cause implicit commits. If you mix DDL and DML inside one transaction, nesting guarantees are limited.
SQLite ✅ Full support, but savepoints are emulated within a single connection. Nested savepoints work, but concurrent access to the same database file may serialize writes differently than on client-server systems.
Other SQL dialects ❌ Not guaranteed. Drivers that do not expose SAVEPOINT will fall back to top-level transactions only.
Additional notes
  • Error semantics: a rollback to a savepoint discards changes made after that point but keeps the transaction open. sqltx follows this strictly, which means inner scopes may fail without aborting the outer scope.
  • Connection pooling: ensure that your *sql.DB or driver connection pool does not automatically reuse connections mid-transaction; database/sql manages this correctly, but custom pools may not.
  • DDL statements: schema-changing operations (CREATE TABLE, ALTER, etc.) often force implicit commits in several databases. such statements cannot be nested reliably under savepoints.
  • Performance: each nested level incurs a SAVEPOINT and RELEASE round-trip. in high-throughput code, prefer shallow nesting or batch operations.

Features

  • Unified API for both transactions and savepoints
  • Smart fallback to savepoints within transactions for nesting
  • Defer-friendly design prevents resource leaks and nesting in your code
  • Zero dependencies beyond the standard library
  • Tested with real-world database scenarios
  • Minimal overhead with no reflection, small wrappers and slim interfaces

Installation

go get -u github.com/0x5a17ed/sqltx

Quick Start

package main

import (
	"context"

	"github.com/0x5a17ed/sqltx"
)

func MyDBOperation(ctx context.Context, dbq sqltx.DBQ) (errOut error) {
	// Start an inner transaction or savepoint.
	tx, leaveFn, err := sqltx.Begin(ctx, dbq)
	if err != nil {
		return err
	}
	defer leaveFn(&errOut)

	// Do some work...
	_, err = tx.ExecContext(ctx, `INSERT INTO cities (name) VALUES (?)`, "Paris")
	if err != nil {
		return err
	}

	return nil
}

How It Works

The primary interface of the sqltx package is a single Begin function that intelligently handles both SQL transactions and savepoints:

func Begin(ctx context.Context, q Querier) (Tx, LeaveFn, error)
  • If q supports transaction initiation (like *sql.DB), a new transaction is started.
  • If q is already a transaction encapsulating *sql.Tx, a savepoint is created.
  • If q is a transaction encapsulating a savepoint, a nested savepoint is created.

The returned LeaveFn clean-up function automatically commits or rolls back the transaction/savepoint appropriately and transparently based on whether the code encounters a panic or an error through observing the error return value of the caller.

Commits and rollbacks apply only to the outermost transaction or specifically requested transactions/savepoints and their nested savepoints.

This design allows for seamless nesting of database operations without the need for manual error handling or explicit transaction management while ensuring that the database is always in a consistent state.

Examples

Nested Transactions
func complexOperation(ctx context.Context, dbq sqltx.DBQ) (errOut error) {
	// Start the outer transaction
	tx1, releaseTx1, err := sqltx.Begin(ctx, dbq)
	if err != nil {
		return err
	}
	defer releaseTx1(&errOut)

	// Do some work...
	_, err = tx1.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS cities (name TEXT NOT NULL UNIQUE);`)
	if err != nil {
		return err
	}

	for _, item := range []string{"Paris", "London", "Berlin"} {
		err = (func() error {
			// Start an inner transaction (becomes a savepoint)
			tx2, releaseTx2, err := sqltx.Begin(ctx, tx1)
			if err != nil {
				return err
			}
			defer releaseTx2(&errOut)

			// Do more work...
			_, err = tx2.ExecContext(ctx, `INSERT INTO cities (name) VALUES (?)`, item)
			return err
		})()

		if err != nil {
			_, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err)
		}
	}

	return nil
}
With Transaction Options
func readOnlyOperation(ctx context.Context, db *sql.DB) (errOut error) {
	// Use read-only transaction.
	tx, releaseTx, err := sqltx.Begin(ctx, sqltx.WithOptions(db, sqltx.OptionReadOnly))
	if err != nil {
		return err
	}
	defer releaseTx(&errOut)

	// Read operations ...

	return nil
}
Works beautifully with sqlc

sqltx pairs naturally with sqlc, the SQL-first code generator for Go.

Both tools share the same philosophy:

  • No ORMs
  • No reflection or runtime magic
  • Predictable, type-safe, and composable design

Use sqlc to generate your query layer and sqltx to manage its transactional scope:

func CreateUser(ctx context.Context, db *sql.DB, arg *sqlc.CreateUserParams) (errOut error) {
    tx, releaseTx, err := sqltx.Begin(ctx, db)
    if err != nil {
        return err
    }
    defer releaseTx(&errOut)

    q := sqlc.New(tx)
    err = q.CreateUser(ctx, *arg)
    return err
}

License

This project is licensed under the 0BSD Licence. See the LICENCE file for details.


Made with ❤️ for elegant database operations

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrNotSupported = errors.New("operation is not support")
	ErrInProgress   = errors.New("nested transaction in progress")
)
View Source
var (
	// OptionReadOnly sets the transaction to read-only.
	OptionReadOnly TxOptionSetter = func(txOpts *sql.TxOptions) {
		txOpts.ReadOnly = true
	}

	// OptionIsolationReadUncommitted sets transaction isolation level [sql.LevelReadUncommitted].
	OptionIsolationReadUncommitted = OptionIsolation(sql.LevelReadUncommitted)

	// OptionIsolationReadCommitted sets transaction isolation level [sql.LevelReadCommitted].
	OptionIsolationReadCommitted = OptionIsolation(sql.LevelReadCommitted)

	// OptionIsolationWriteCommitted sets transaction isolation level [sql.LevelWriteCommitted].
	OptionIsolationWriteCommitted = OptionIsolation(sql.LevelWriteCommitted)

	// OptionIsolationRepeatableRead sets the transaction isolation level to [sql.LevelRepeatableRead].
	OptionIsolationRepeatableRead = OptionIsolation(sql.LevelRepeatableRead)

	// OptionIsolationSnapshot sets the transaction isolation level to [sql.LevelSnapshot].
	OptionIsolationSnapshot = OptionIsolation(sql.LevelSnapshot)

	// OptionIsolationSerializable sets the transaction isolation level to [sql.LevelSerializable].
	OptionIsolationSerializable = OptionIsolation(sql.LevelSerializable)

	// OptionIsolationLinearizable sets the transaction isolation level to [sql.LevelLinearizable].
	OptionIsolationLinearizable = OptionIsolation(sql.LevelLinearizable)
)

Functions

func Begin

func Begin(ctx context.Context, q DBQ) (out *Tx, fn LeaveFn, err error)

Begin starts a transaction or savepoint.

The returned LeaveFn function must be called, typically within a defer statement, to commit or rollback the transaction. The returned error is ErrNotSupported if the given DBQ does not support transactions or any error returned by the Begin method of the DBQ.

func WrapDriver

func WrapDriver(name string, originalDriver driver.Driver) error

WrapDriver wraps a driver and registers it with the given name. The original driver doesn't need to be registered before this call.

Types

type DBQ

type DBQ interface {
	ExecContext(context.Context, string, ...any) (sql.Result, error)
	PrepareContext(context.Context, string) (*sql.Stmt, error)
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
	QueryRowContext(context.Context, string, ...any) *sql.Row
}

DBQ provides querying access to a database.

type LeaveFn

type LeaveFn func(*error)

LeaveFn should be deferred to handle transaction committing or rolling back.

It's returned by the Begin function. The function accepts a pointer to an error that will be inspected for any errors that occurred during the transaction. If the error is not nil, the transaction will be rolled back. If the error is nil, the transaction will be committed.

type Options

type Options struct {
	// contains filtered or unexported fields
}

func WithOptions

func WithOptions(db *sql.DB, opts ...TxOptionSetter) *Options

WithOptions creates a new Options instance.

func (*Options) Begin

func (o *Options) Begin(ctx context.Context) (*Tx, error)

Begin starts a new database transaction.

type Root

type Root struct {
	DBQ
	// contains filtered or unexported fields
}

func NewRoot

func NewRoot(qex DBQ, drv driver.Driver) *Root

func (*Root) DBDriver

func (rt *Root) DBDriver() driver.Driver

func (*Root) NextSavepointID

func (rt *Root) NextSavepointID() uint64

NextSavepointID provides a sequential ID intended for savepoint naming.

type RootQuerier

type RootQuerier interface {
	DBQ
	DBDriver() driver.Driver
	NextSavepointID() uint64
}

type Savepoint

type Savepoint struct {
	// contains filtered or unexported fields
}

Savepoint represents a savepoint within a transaction.

func NewSavepoint

func NewSavepoint(ctx context.Context, qex DBQ, name string) (*Savepoint, error)

NewSavepoint creates a new savepoint.

func (*Savepoint) Commit

func (sp *Savepoint) Commit() error

Commit commits the savepoint.

func (*Savepoint) Name

func (sp *Savepoint) Name() string

Name returns the name of the savepoint.

func (*Savepoint) Rollback

func (sp *Savepoint) Rollback() error

Rollback rolls back the savepoint.

type Tx

type Tx struct {
	// contains filtered or unexported fields
}

func NewTransaction

func NewTransaction(root RootQuerier, txc TxController, pt *Tx) *Tx

NewTransaction constructs a new transaction handle.

func (*Tx) Begin

func (tx *Tx) Begin(ctx context.Context) (*Tx, error)

Begin marks a savepoint.

func (*Tx) Commit

func (tx *Tx) Commit() error

Commit commits the changes made in the transaction or releases the savepoint.

func (*Tx) Error

func (tx *Tx) Error() (err error)

func (*Tx) ExecContext

func (tx *Tx) ExecContext(ctx context.Context, query string, args ...any) (out sql.Result, errOut error)

ExecContext executes a query that doesn't return rows within the transaction.

See sql.Tx.ExecContext <https://pkg.go.dev/database/sql#Tx.ExecContext> for more information.

func (*Tx) PrepareContext

func (tx *Tx) PrepareContext(ctx context.Context, query string) (out *sql.Stmt, errOut error)

PrepareContext creates a prepared statement for use within a transaction.

See sql.Tx.PrepareContext <https://pkg.go.dev/database/sql#Tx.PrepareContext> for more information.

func (*Tx) QueryContext

func (tx *Tx) QueryContext(ctx context.Context, query string, args ...any) (out *sql.Rows, errOut error)

QueryContext executes a query that returns rows, typically a SELECT, within the transaction.

See sql.Tx.QueryContext <https://pkg.go.dev/database/sql#Tx.QueryContext> for more information.

func (*Tx) QueryRowContext

func (tx *Tx) QueryRowContext(ctx context.Context, query string, args ...any) (out *sql.Row)

QueryRowContext executes a query, that is expected to return at most one row, within the transaction.

See sql.Tx.QueryRowContext <https://pkg.go.dev/database/sql#Tx.QueryRowContext> for more information.

func (*Tx) Rollback

func (tx *Tx) Rollback() error

Rollback rolls back changes made in the transaction or savepoint.

type TxController

type TxController interface {
	Commit() error
	Rollback() error
}

TxController can roll back transactions.

type TxOptionSetter

type TxOptionSetter func(txOpts *sql.TxOptions)

TxOptionSetter supplies options for a transaction.

func OptionIsolation

func OptionIsolation(level sql.IsolationLevel) TxOptionSetter

OptionIsolation sets the transaction isolation level.

Directories

Path Synopsis
internal

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL