rio

July 10, 2026 · View on GitHub

Go Reference Go Release Test License

Generic ORM for Go. Query values carry no connection; writes are explicit; relations are typed and never lazy-load. Faster than GORM; close to hand-written database/sql.

rio's core has zero dependencies. Drivers are separate modules:

ModuleDriver
go-rio/postgrespgx
go-rio/mysqlgo-sql-driver
go-rio/sqlitemodernc, pure Go
go-rio/clickhouseclickhouse-go v2
db, _ := postgres.Open(dsn)

users, err := rio.From[User]().
    Where("age > ?", 18).
    OrderBy("created_at DESC").Limit(10).
    With("Posts", rio.RelWhere("published = ?", true)).
    All(ctx, db)

Why rio

GuaranteeDetail
Immutable query valuesBuilders carry no connection or state; concurrent, package-level derivation from a shared base cannot cross-contaminate (GORM's "condition pollution").
Zero values are realUpdate writes what you give it, including 0, false, and ""; it never silently skips fields (GORM issue #6860). Partial updates are an explicit column whitelist; DB defaults are an explicit omitzero tag.
No hidden queriesTyped containers (rio.HasMany[Post]) know whether they loaded; accessing an unloaded relation panics with instructions. No lazy loading, no invisible N+1.
Go-style errorsErrNotFound wraps sql.ErrNoRows (both errors.Is checks pass); unique violations translate to rio.ErrDuplicateKey with the driver error intact in the chain. Nothing is logged for you.
Guarded set-based writesUpdateAll/DeleteAll refuse to run without a WHERE unless you call AllRows(), on every dialect.
Predictable SQLOne builder call, one SQL fragment; ? placeholders everywhere, with IN (?) slice expansion. The escape hatch rio.Raw[T] shares the scanner, hooks, and transactions.

Install

go get github.com/go-rio/rio
go get github.com/go-rio/postgres   # or go-rio/mysql, go-rio/sqlite, go-rio/clickhouse

Quickstart

package main

import (
    "context"
    "time"

    "github.com/go-rio/rio"
    "github.com/go-rio/sqlite"
)

type User struct {
    ID        int64
    Email     string
    Age       int
    Bio       *string    // nullable
    Version   int64      `rio:",version"`
    DeletedAt *time.Time `rio:",softdelete"`
    CreatedAt time.Time
    UpdatedAt time.Time

    Posts rio.HasMany[Post]
}

type Post struct {
    ID     int64
    UserID int64
    Title  string

    Author rio.BelongsTo[User] `rio:",fk:user_id"`
}

func main() {
    ctx := context.Background()
    db, _ := sqlite.Open("file:app.db")

    u := &User{Email: "alice@example.com", Age: 30}
    _ = rio.Insert(ctx, db, u) // u.ID, timestamps, version filled in

    u.Age = 31
    _ = rio.Update(ctx, db, u)            // full row, version-checked
    _ = rio.Update(ctx, db, u, "age")     // just age (+updated_at)

    user, _ := rio.Find[User](ctx, db, u.ID) // by primary key

    _ = db.Tx(ctx, func(tx *rio.Tx) error { // nested Tx = savepoints
        return rio.Insert(ctx, tx, &Post{UserID: u.ID, Title: "hello"})
    })

    _ = user
}

Schema migrations are out of scope. rio pairs with go-rio/migrate, where migrations are Go code compiled into your binary.

Model mapping

DeclarationMeaning
ID int64primary key + auto-increment, by convention; a rename or other role-neutral tag keeps it — an explicit ,pk anywhere in the model opts out
rio:"col_name"rename the column (default: snake_case, UserIDuser_id)
rio:",pk"primary key (tag several fields for composite keys)
rio:",noautoincr"integer single PK that rio must not treat as auto-increment
rio:",version"optimistic locking: UPDATE … SET version = version+1 WHERE … AND version = ?; a lost race returns ErrStaleObject
rio:",softdelete"on time.Time/*time.Time: Delete becomes an UPDATE, default queries filter, WithTrashed/OnlyTrashed/ForceDelete opt out
rio:",json"(de)serialize the field as JSON
rio:",countof:Posts"int64 target that WithCount("Posts") fills with the related row count (HasMany/ManyToMany)
rio:",omitzero"skip the column when zero so DB defaults apply (and RETURNING fills it back); a single-row Upsert conflict then leaves the existing value untouched
rio:"-"not a column
CreatedAt, UpdatedAtmaintained automatically when present (rio:",nostamp" opts out)
TableName() stringoverride the pluralized table name (Userusers, Personpeople)

Relation types: rio.HasMany[T], rio.HasOne[T], rio.BelongsTo[T], rio.ManyToMany[T], with fk:, ref:, and join: tag overrides. With takes the Go field name (With("Posts")); column APIs (Update whitelists, rio.Set, OnConflict, DoUpdate) take database column names. Preloading always uses per-relation WHERE … IN split queries: no cartesian explosion, pagination stays correct, identical behavior on all dialects. Paths nest: With("Posts.Comments").

Semantics

OperationBehavior
First/Find/Sole missrio.ErrNotFound (wraps sql.ErrNoRows), never logged
All missempty slice, nil error
Sole with 2+ rowsrio.ErrMultipleRows
version conflictrio.ErrStaleObject
Update/Delete matching no row (no version column)rio.ErrNotFound
UpdateAll/DeleteAll without WHERErio.ErrMissingWhere
unique / FK violationrio.ErrDuplicateKey / rio.ErrForeignKeyViolated
NULL into a non-pointer fielderror naming the column, not a silent zero
MySQL Insertfills the auto-increment ID; no hidden second SELECT
Upsert onto a soft-deleted rowDoUpdate revives it (rio.KeepTrashed() opts out); DoNothing never revives
timesstored UTC, microsecond precision; insert-then-reload compares Equal

Upsert supports conflict target, update whitelist, RETURNING backfill, and timestamp maintenance:

err := rio.Upsert(ctx, db, &user, rio.OnConflict("email"), rio.DoUpdate("name"))

On MySQL the DoUpdate branch uses the 8.0.19+ row-alias syntax (VALUES() is deprecated); MySQL before 8.0.19 and MariaDB support DoNothing only.

Batch writes chunk automatically to each dialect's bind limit; backfill promises only what dialects can keep (per-dialect notes on InsertAll).

Compiled queries

Entity CRUD is cached automatically; hand-built queries compile once, regexp.MustCompile style:

var adults = rio.MustCompile[User](
    rio.From[User]().Where("age > ?").OrderBy("created_at DESC").Limit(10),
)

users, err := adults.All(ctx, db, 18) // binds parameters only

Structural problems panic at startup; SQL renders lazily per dialect and is cached. rio.WithStmtCache() also caches prepared statements (off by default: PgBouncer transaction pooling breaks server-side prepared statements; enable only when talking to the database directly).

Column constants

rio.WriteColumns generates column references from rio's own mapping plans: no source parsing, no binary to install, output cannot drift from runtime behavior:

//go:generate sh -c "go run ./internal/gencols > cols_gen.go"
// internal/gencols: rio.WriteColumns(os.Stdout, "models", User{}, Post{})

users, err := rio.From[User]().Where(UserCols.Email+" = ?", e).All(ctx, db)

Pagination

Offset pagination degrades linearly. Keyset pagination is a WHERE clause; rio provides the pattern, not an API:

// Page 1: rio.From[Post]().OrderBy("created_at DESC, id DESC").Limit(20)
// Next page, keyed by the last row you handed out:
next, err := rio.From[Post]().
    Where("(created_at, id) < (?, ?)", last.CreatedAt, last.ID).
    OrderBy("created_at DESC, id DESC").Limit(20).
    All(ctx, db)

Row-value comparison works on PostgreSQL, MySQL, and SQLite 3.15+. For result sets too large to page, stream with Rows.

Observability

QueryHook sees every statement rio runs — op, model, SQL, args (redactable with rio.WithoutArgs()), duration, rows affected — and cannot alter them. There are no model hooks.

type SlogHook struct {
    Log  *slog.Logger
    Slow time.Duration // statements at or over this log at WARN
}

func (SlogHook) BeforeQuery(ctx context.Context, e *rio.QueryEvent) context.Context {
    return ctx
}

func (h SlogHook) AfterQuery(ctx context.Context, e *rio.QueryEvent) {
    level := slog.LevelDebug
    switch {
    case e.Err != nil:
        level = slog.LevelError
    case h.Slow > 0 && e.Duration >= h.Slow:
        level = slog.LevelWarn
    }
    h.Log.LogAttrs(ctx, level, "query",
        slog.String("op", e.Op), slog.String("model", e.Model),
        slog.Duration("dur", e.Duration), slog.Int64("rows", e.RowsAffected),
        slog.String("sql", e.Query), slog.Any("err", e.Err))
}

db, _ := postgres.Open(dsn, rio.WithQueryHook(SlogHook{
    Log: slog.Default(), Slow: 200 * time.Millisecond,
}))

The context BeforeQuery returns is the execution context rio hands the driver, so tracing spans and deadlines you attach there flow into the query, and AfterQuery receives that same context. To emit OpenTelemetry spans, start a span in BeforeQuery (return its context) and End() it in AfterQuery, recording Op and Model as span attributes.

Security

rio sorts every string argument into two kinds:

ArgumentAPIsTreatment
Column namesUpdate whitelist, rio.Set keys, Pluck, OnConflict/DoUpdate, With pathsvalidated against the model's mapped columns (relation names for With) and emitted as escaped identifiers; an unknown name errors, never injects SQL
SQL fragmentsWhere, OrderBy, GroupBy, Having, Join, RelWhere, Expr, Rawrendered verbatim — build them from constants, never from untrusted input

Values bind as ? parameters everywhere; only fragment text is verbatim. For an identifier chosen at runtime, map user input to a column constant (rio.WriteColumns) instead of concatenating it:

allowed := map[string]string{"newest": UserCols.CreatedAt, "name": UserCols.Name}
col, ok := allowed[userInput]; if !ok { col = UserCols.ID } // reject unknown keys
users, err := rio.From[User]().OrderBy(col + " DESC").All(ctx, db)

Performance

Benchmarked against GORM and hand-written database/sql on the same pure-Go SQLite driver (Apple M4; rio/bench, reproducible with go test -bench . -benchmem ./...):

Scenarioriohand-writtenGORM
read one (compiled)8.8 µs7.8 µs11.6 µs
read one (Find)8.8 µs7.8 µs11.6 µs
read 100 rows119 µs120 µs158 µs
insert11.0 µs7.8 µs19.9 µs
update6.7 µs6.2 µs15.5 µs
insert 100 (batch)262 µs294 µs
  • Reads: ~25% faster than GORM, within ~12% of hand-written scanning; the 100-row scan is a dead heat.
  • Inserts: ~45% faster than GORM. Updates: ~57% faster than GORM, within 7% of hand-written SQL.
  • Batch inserts are driver-dominated (both sides send one multi-VALUES statement), so the ~11% edge is mostly allocation discipline.
  • Techniques: per-type mapping plans, per-grammar SQL caches, offset-based scanning with a reflect fallback, []byte-appended rendering. No code generation.

What rio does not have

  • No model hooks
  • No implicit lazy loading
  • No dirty tracking, unit of work, or identity map
  • No AutoMigrate
  • No second-level cache
  • No association auto-writes
  • No client-side evaluation

Rationale for each is in DESIGN.md.

License

rio is released under the MIT License, © 2026-now TreeNewBee.