Production-ready, lightweight ORM and query builder for PostgreSQL on top of PGX v5. Ships with connection pooling, automatic migrations from struct tags, a fluent query builder, generic repository, soft delete, optimistic locking, transactions, read/write splitting, retry/backoff, a circuit breaker, and comprehensive e2e tests.
- Fast, reliable connections via PGX v5 (
pgxpool
) - Flexible
Config
: pool limits, timeouts, statement cache, app name, etc. - Auto-migration from struct tags: tables/columns/indexes/FKs, idempotent plan, transactional apply, rename diffs, type/nullability warnings
- Query builder:
Select/Where/Join/OrderBy/Limit/Offset
,Raw
,First/Last
,Delete
(soft by default,HardDelete()
to force hard),INSERT ... RETURNING
,ON CONFLICT DO UPDATE
- Condition DSL:
Eq/Ne/Gt/Ge/Lt/Le/In/And/Or
, date helpers - Keyset pagination:
After/Before
- Repository: generic CRUD, bulk create, partial update, soft delete, scopes, optimistic locking
- Transactions:
TxManager
, transaction-bound QueryBuilder - Read/Write splitting: optional read pool and transparent routing
- Retry: exponential backoff
- Circuit Breaker: optional open/half-open/closed with metrics hooks
Note: OpenTelemetry/Prometheus integrations are not included yet.
go get github.com/kintsdev/norm
package main
import (
"context"
"time"
"github.com/kintsdev/norm"
)
type User struct {
ID int64 `db:"id" norm:"primary_key,auto_increment"`
Email string `db:"email" norm:"unique,not_null,index,varchar(255)"`
Username string `db:"username" norm:"unique,not_null,varchar(50)"`
Password string `db:"password" norm:"not_null,varchar(255)"`
IsActive bool `db:"is_active" norm:"default:true"`
CreatedAt time.Time `db:"created_at" norm:"not_null,default:now()"`
UpdatedAt time.Time `db:"updated_at" norm:"not_null,default:now(),on_update:now()"`
DeletedAt *time.Time `db:"deleted_at" norm:"index"`
Version int64 `db:"version" norm:"version"`
}
func main() {
cfg := &norm.Config{
Host: "127.0.0.1", Port: 5432, Database: "postgres", Username: "postgres", Password: "postgres",
SSLMode: "disable", StatementCacheCapacity: 256,
}
kn, _ := norm.New(cfg)
defer kn.Close()
// Auto-migrate schema from struct tags
_ = kn.AutoMigrate(&User{})
// Repository
repo := norm.NewRepository[User](kn)
_ = repo.Create(context.Background(), &User{Email: "u@example.com", Username: "u", Password: "x"})
// Query builder
var users []User
_ = kn.Query().Table("users").Where("is_active = ?", true).OrderBy("id ASC").Limit(10).Find(context.Background(), &users)
}
db:"column_name"
: Column name; if empty, the field name is converted to snake_case.norm:"..."
: Primary tag for schema/behavior (legacyorm:"..."
still works as a fallback).
Supported norm
tokens (mix and match, comma separated):
- Primary key:
primary_key
, composite viaprimary_key:group
- Auto-increment identity:
auto_increment
- Unique:
unique
, composite viaunique:group
, optional index name viaunique_name:name
- Indexing:
index
,index:name
, index methodusing:gin|btree|hash
, partial indexindex_where:(expr)
- Foreign keys:
fk:other_table(other_id)
,fk_name:name
, actionson_delete:cascade|restrict|set null|set default
, optionaldeferrable
,initially_deferred
- Nullability:
not_null
, or explicitnullable
- Default:
default:<expr>
(e.g.,default:now()
) - On update:
on_update:now()
(repository auto-sets NOW() on update for such columns) - Version column for optimistic locking:
version
(treated as BIGINT) - Rename diff:
rename:old_column
- Collation:
collate:<name>
- Comment:
comment:...
- Type override:
type:decimal(20,8)
or direct types likevarchar(50)
,text
,timestamptz
,numeric(10,2)
,citext
- Ignore field:
-
orignore
(excluded from migrations and insert/update helpers)
Examples:
// Composite unique
Slug string `db:"slug" norm:"not_null,unique:tenant_slug"`
Tenant int64 `db:"tenant_id" norm:"not_null,unique:tenant_slug,unique_name:uq_accounts_tenant_slug"`
// Partial index and method
Email string `db:"email" norm:"index,using:gin,index_where:(deleted_at IS NULL)"`
// Decimal override
Amount float64 `db:"amount" norm:"type:decimal(20,8)"`
// FK with actions
UserID int64 `db:"user_id" norm:"not_null,fk:users(id),on_delete:cascade,fk_name:fk_posts_user"`
- Plan/preview: reads current schema via
information_schema
, builds a safe plan - Creates tables/columns, composite indexes/uniques, and foreign keys (with actions)
- Rename-safe diffs:
ALTER TABLE ... RENAME COLUMN ...
- Type/nullability changes produce warnings and unsafe statements
- Transactional apply with advisory lock
- Records checksums in
schema_migrations
(idempotent)
Manual migrations (file-based Up/Down) and rollback support exist with safety guards; see migration
package and tests.
- If
Config.ReadOnlyConnString
is set, a read pool is opened andQuery()
routes read queries there automatically. Writes go to primary. - Override per-query:
UsePrimary()
orUseReadPool()
. - Retry with
RetryAttempts
andRetryBackoff
(exponential + jitter). - Circuit breaker:
CircuitBreakerEnabled
,CircuitFailureThreshold
,CircuitOpenTimeout
,CircuitHalfOpenMaxCalls
.
- Provide a cache via
WithCache(cache)
(e.g., a Redis adapter) - Read-through:
Query().WithCacheKey(key, ttl).Find/First
- Invalidation:
WithInvalidateKeys(keys...).Exec/Insert/Update/Delete
- Make targets spin up Postgres 17.5 in Docker and run e2e tests
make db-up
make test-e2e
make db-down
Micro and end-to-end benchmarks are included. Run micro (no DB required) or full (requires Postgres env like the e2e tests).
Run all benchmarks (micro + e2e):
go test -bench=. -benchmem -run=^$ ./...
Only micro (root package):
go test -bench=. -benchmem -run=^$
Examples (Apple M3, Go 1.22, local PG):
-
Placeholder conversion and builder (ns–µs level)
ConvertQMarksToPgPlaceholders
: ~250 ns/op, 208 B/op, 9 alloc/opConvertNamedToPgPlaceholders
(scalars/reuse, slice expansion): ~390–690 ns/opStructMapper
(cached): ~9 ns/op, 0 alloc/opBuild SELECT with JOINs
: ~1.1 µs/op
-
E2E (depends on DB latency)
FindPage
(COUNT + SELECT): ~0.3 ms/opScan 100 rows
: ~0.25–0.30 ms/opCopyFrom(500 rows)
: ~0.08 ms/op- Single-row writes (Insert/Upsert/Tx): ~6–7 ms/op
Notes:
- Results vary by CPU, Go version, and Postgres settings; numbers above are indicative.
- Micro benchmarks live in
bench_test.go
, e2e ine2e/bench_e2e_test.go
.
This project is licensed under the MIT License. See the LICENSE
file for details.