Latest stable: v4.3.0
Lightweight PostgreSQL SQL query helper for Go structs.
- Not an ORM
- No hidden database calls
- No connection management
- Works with pgx, lib/pq and database/sql
- Generates SELECT / INSERT / UPDATE / DELETE SQL from Go structs
- Handles bind placeholders, field lists, scan pointers and simple migrations
- 3–6x faster than common Go query builders in pure SQL generation benchmarks
In many Go services you want to keep raw SQL control, but still avoid this boilerplate:
- manually listing columns in every INSERT / UPDATE / SELECT
- keeping
$1, $2, $3...placeholders in sync - duplicating scan pointers
- repeating struct-to-table metadata
- writing small safe schema sync helpers for internal tools and MVPs
norm solves that middle layer: less boilerplate than raw SQL, less magic than ORM.
| Tool | Best for | Trade-off |
|---|---|---|
| GORM | full ORM, relationships, hooks | more magic, less SQL control |
| Bun | SQL-first ORM | still an ORM layer |
| sqlc | type-safe code from hand-written SQL | requires SQL files and generation step |
| squirrel/goqu | dynamic query building | no struct-driven INSERT/UPDATE/scan helpers |
| norm | struct-driven SQL generation with manual execution | PostgreSQL-focused, not a full ORM |
- Why?
- Comparison
- Install
- Quick start
- Examples
- Core concepts
- Field and table naming
- Struct tags
- Embedded structs
- JSON struct fields
- Query building
- WHERE conditions builder
- Code generation
- Migrations
- Options reference
- Model methods reference
- Join methods reference
- Norm methods reference
- Migrate methods reference
- Benchmarks
- Important notes
go get github.com/juggle73/norm/v4package main
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/juggle73/norm/v4"
"github.com/juggle73/norm/v4/migrate"
)
type User struct {
Id int64 `norm:"pk"`
Name string `norm:"notnull"`
Email string
}
func main() {
ctx := context.Background()
pool, _ := pgxpool.New(ctx, "postgres://localhost/mydb")
orm := norm.NewNorm(nil)
// Register models
orm.AddModel(&User{}, "users")
// Sync tables (create if not exist, add missing columns)
db, _ := sql.Open("pgx", "postgres://localhost/mydb")
mig := migrate.New(db, orm)
_ = mig.Sync(ctx)
// SELECT
user := User{}
m, _ := orm.M(&user)
sql, args, _ := m.Select(norm.Where("id = ?", 42))
// → "SELECT id, name, email FROM users WHERE id=$1"
_ = pool.QueryRow(ctx, sql, args...).Scan(m.Pointers()...)
// INSERT
newUser := User{Name: "Alice", Email: "alice@example.com"}
m, _ = orm.M(&newUser)
sql, vals, _ := m.Insert(norm.Exclude("id"), norm.Returning("Id"))
// → "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id"
_ = pool.QueryRow(ctx, sql, vals...).Scan(m.Pointer("Id"))
// UPDATE
user.Name = "Bob"
m, _ = orm.M(&user)
sql, args, _ = m.Update(norm.Exclude("id"), norm.Where("id = ?", user.Id))
// → "UPDATE users SET name=$1, email=$2 WHERE id=$3"
_, _ = pool.Exec(ctx, sql, args...)
// DELETE
sql, args, _ = m.Delete(norm.Where("id = ?", user.Id))
// → "DELETE FROM users WHERE id=$1"
_, _ = pool.Exec(ctx, sql, args...)
}Runnable, self-contained examples live in examples/. Each one is
an independent module wired to a throwaway PostgreSQL container — just:
cd examples/<name>
docker compose up -d
go run .| Example | Shows |
|---|---|
pgx_crud |
Full CRUD cycle over a pgx pool |
upsert |
INSERT ... ON CONFLICT (UPSERT) |
dynamic_filters |
Runtime WHERE building with BuildConditions |
joins |
Explicit and FK-driven (Auto) joins |
migration_sync |
Sync, Diff and CreateTableSQL |
json_fields |
Struct/map fields as jsonb and ->> queries |
Norm is the entry point. It caches struct metadata so reflection only happens once per type.
orm := norm.NewNorm(nil) // default config
orm := norm.NewNorm(&norm.Config{
DefaultString: "varchar", // default: "text"
DefaultTime: "timestamp", // default: "timestamptz"
DefaultJSON: "json", // default: "jsonb"
JSONMarshal: sonic.Marshal, // default: encoding/json
JSONUnmarshal: sonic.Unmarshal,
})Model is a lightweight wrapper that binds cached metadata to a specific struct instance. Each call to M() returns a new Model bound to the given pointer.
user := User{Id: 1, Name: "John"}
m, err := orm.M(&user)
// m.Values() → reads from &user
// m.Pointers() → points into &userModel is not safe for concurrent use. Metadata caching is thread-safe, but each goroutine should get its own Model via M().
Use AddModel to register a struct with a custom table name:
orm.AddModel(&User{}, "app_users") // table name = "app_users"Without AddModel, M() auto-generates the table name from the struct name in snake_case (User -> user, UserProfile -> user_profile).
All names are automatically converted to snake_case. This applies to both table names and column names:
- Struct
UserProfile→ tableuser_profile - Field
UserName→ columnuser_name - Field
CreatedAt→ columncreated_at
If your database uses a different naming convention, use dbName tag to override:
type User struct {
UserName string // → column "user_name"
UserName string `norm:"dbName=username"` // → column "username"
}Table names can be overridden with AddModel:
orm.AddModel(&User{}, "app_users") // table "app_users" instead of "user"Important: norm will not match fields to columns automatically if they don't follow snake_case convention. If your column is username but your field is UserName (which maps to user_name), you must add norm:"dbName=username".
Fields are configured via the norm struct tag:
type User struct {
Id int `norm:"pk"`
Email string `norm:"unique,notnull"`
Name string `norm:"dbName=full_name"`
Role string `norm:"notnull,default='user'"`
Data string `norm:"dbType=jsonb"`
Internal string `norm:"-"` // skip this field
CreatedAt time.Time // no tag = auto snake_case name
}| Tag | Description |
|---|---|
pk |
Mark as primary key (supports composite) |
unique |
Add UNIQUE constraint |
notnull |
Add NOT NULL constraint |
default=value |
Set DEFAULT value |
dbName=name |
Override column name (default: snake_case of field name) |
dbType=type |
Override PostgreSQL type |
fk=ModelName |
Mark as foreign key (accepts any format: UserType, userType, user_type) |
- |
Skip field entirely |
norm supports Go struct embedding for sharing common fields:
type BaseModel struct {
Id int `norm:"pk"`
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct {
BaseModel
Name string
Email string `norm:"unique"`
}
// m.Fields() → "id, created_at, updated_at, name, email"Both value and pointer embeddings are supported (BaseModel and *BaseModel).
Fields of type struct or *struct (except time.Time) are automatically marshaled to JSON when writing and unmarshaled when reading. No tags required:
type Address struct {
City string `json:"city"`
Street string `json:"street"`
}
type User struct {
Id int `norm:"pk"`
Name string
Address Address // automatically handled as JSON
}
orm := norm.NewNorm(nil)
// INSERT — Address is marshaled to JSON bytes
user := User{Name: "Alice", Address: Address{City: "Moscow", Street: "Tverskaya"}}
m, _ := orm.M(&user)
sql, vals, _ := m.Insert(norm.Exclude("id"))
// vals = ["Alice", []byte(`{"city":"Moscow","street":"Tverskaya"}`)]
// SELECT — Address is unmarshaled from JSON
var loaded User
m, _ = orm.M(&loaded)
sql, args, _ = m.Select(norm.Where("id = ?", 1))
_ = pool.QueryRow(ctx, sql, args...).Scan(m.Pointers()...)
// loaded.Address.City == "Moscow"Pointer struct fields (*Address) work the same way. nil pointers marshal to null.
By default encoding/json is used. For better performance, plug in a faster codec via Config:
import "github.com/bytedance/sonic"
orm := norm.NewNorm(&norm.Config{
JSONMarshal: sonic.Marshal,
JSONUnmarshal: sonic.Unmarshal,
})The simplest way — use m.Select():
user := User{}
m, _ := orm.M(&user)
sql, args, _ := m.Select(norm.Where("id = ?", 42))
// sql = "SELECT id, name, email FROM users WHERE id=$1"
err := pool.QueryRow(ctx, sql, args...).Scan(m.Pointers()...)With all options:
sql, args, _ := m.Select(
norm.Exclude("password"),
norm.Where("active = ?", true),
norm.Order("Name DESC"),
norm.Limit(10),
norm.Offset(20),
)
// → "SELECT id, name, email FROM users WHERE active=$1 ORDER BY name DESC LIMIT 10 OFFSET 20"You can also build SELECT manually with individual methods:
sql := fmt.Sprintf("SELECT %s FROM %s WHERE id=$1", m.Fields(), m.Table())
err := pool.QueryRow(ctx, sql, 42).Scan(m.Pointers()...)Use m.Insert() — returns SQL and values from the bound struct:
user := User{Name: "Alice", Email: "alice@example.com"}
m, _ := orm.M(&user)
sql, vals, _ := m.Insert(norm.Exclude("id"))
// sql = "INSERT INTO users (name, email) VALUES ($1, $2)"
// vals = ["Alice", "alice@example.com"]
_, err := pool.Exec(ctx, sql, vals...)sql, vals, _ := m.Insert(norm.Exclude("id"), norm.Returning("Id"))
// → "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id"
err := pool.QueryRow(ctx, sql, vals...).Scan(m.Pointer("Id"))PostgreSQL INSERT ... ON CONFLICT is built from the same Insert() call via the
OnConflict() option. norm only generates the SQL and args — execution stays yours.
Finalize the clause with DoNothing() or DoUpdate(...). Conflict and update
columns accept any field-name format (struct, camelCase, snake_case) and are
validated against the model.
DO NOTHING — insert, or skip silently on conflict:
sql, vals, _ := m.Insert(
norm.Exclude("id"),
norm.OnConflict("email").DoNothing(),
)
// → "INSERT INTO users (name, email) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING"DO UPDATE — insert, or update the listed columns on conflict. Each column is
set to its proposed value via EXCLUDED:
sql, vals, _ := m.Insert(
norm.Exclude("id"),
norm.OnConflict("email").DoUpdate("name", "updated_at"),
)
// → "INSERT INTO users (name, email, updated_at) VALUES ($1, $2, $3)
// ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at"DO UPDATE + RETURNING — the clause order is always INSERT … ON CONFLICT … RETURNING:
sql, vals, _ := m.Insert(
norm.Exclude("id"),
norm.OnConflict("email").DoUpdate("name"),
norm.Returning("id"),
)
// → "INSERT INTO users (name, email) VALUES ($1, $2)
// ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name RETURNING id"
err := pool.QueryRow(ctx, sql, vals...).Scan(m.Pointer("Id"))Composite conflict target — pass multiple columns to OnConflict:
sql, vals, _ := m.Insert(
norm.Exclude("id"),
norm.OnConflict("provider", "external_id").DoUpdate("payload"),
)
// → "... ON CONFLICT (provider, external_id) DO UPDATE SET payload = EXCLUDED.payload"Option order does not matter. Insert returns an error if: no conflict columns
are given, DoUpdate has no columns, a column name is unknown, both DoNothing
and DoUpdate are set, or more than one OnConflict is passed.
Out of scope for now:
ON CONSTRAINT, partial-index predicates (ON CONFLICT (...) WHERE ...), custom update expressions, aWHEREafterDO UPDATE, and multi-row UPSERT.
Use m.Update() — builds SET clause from bound struct values, chains bind numbers into WHERE:
user := User{Id: 1, Name: "Bob", Email: "bob@new.com"}
m, _ := orm.M(&user)
sql, args, _ := m.Update(norm.Exclude("id"), norm.Where("id = ?", user.Id))
// sql = "UPDATE users SET name=$1, email=$2 WHERE id=$3"
// args = ["Bob", "bob@new.com", 1]
_, err := pool.Exec(ctx, sql, args...)With RETURNING:
sql, args, _ := m.Update(
norm.Exclude("id"),
norm.Where("id = ?", user.Id),
norm.Returning("Id"),
)
// → "UPDATE users SET name=$1, email=$2 WHERE id=$3 RETURNING id"You can also build UPDATE manually with UpdateFields and BuildWhere:
set, nextBind := m.UpdateFields(norm.Exclude("id"))
whereStr, whereArgs := norm.BuildWhere(nextBind, "id = ?", user.Id)
sql := fmt.Sprintf("UPDATE %s SET %s WHERE %s", m.Table(), set, whereStr)
args := append(m.Values(norm.Exclude("id")), whereArgs...)
_, err := pool.Exec(ctx, sql, args...)Use m.Delete():
m, _ := orm.M(&User{})
sql, args, _ := m.Delete(norm.Where("id = ?", 42))
// sql = "DELETE FROM users WHERE id=$1"
// args = [42]
_, err := pool.Exec(ctx, sql, args...)With RETURNING:
sql, args, _ := m.Delete(norm.Where("id = ?", 42), norm.Returning("Id"))
// → "DELETE FROM users WHERE id=$1 RETURNING id"Use NewJoin to build SELECT queries across multiple tables. Fields are auto-prefixed with table names, and Pointers() collects scan targets from all models:
user := User{}
order := Order{}
mUser, _ := orm.M(&user)
mOrder, _ := orm.M(&order)
j := norm.NewJoin(mUser).
Inner(mOrder, "orders.user_id = users.id").
Where("users.active = ?", true).
Order("users.name DESC").
Limit(10)
sql, args, _ := j.Select()
// SELECT users.id, users.name, users.email, orders.id, orders.user_id, orders.total
// FROM users
// INNER JOIN orders ON orders.user_id = users.id
// WHERE users.active=$1
// ORDER BY users.name DESC LIMIT 10
err := pool.QueryRow(ctx, sql, args...).Scan(j.Pointers()...)
// user and order are populatedMultiple JOINs:
item := OrderItem{}
mItem, _ := orm.M(&item)
j := norm.NewJoin(mUser).
Inner(mOrder, "orders.user_id = users.id").
Left(mItem, "order_items.order_id = orders.id").
Where("users.id = ?", 1)
sql, args, _ := j.Select()
err := pool.QueryRow(ctx, sql, args...).Scan(j.Pointers()...)Supported join types: Inner, Left, Right.
If your structs have fk tags, use Auto / AutoLeft to build JOINs without writing ON clauses:
type User struct {
Id int `norm:"pk"`
Name string
}
type Order struct {
Id int `norm:"pk"`
UserId int `norm:"fk=User"`
Total int
}
type OrderItem struct {
Id int `norm:"pk"`
OrderId int `norm:"fk=Order"`
Product string
}
mUser, _ := orm.M(&user)
mOrder, _ := orm.M(&order)
mItem, _ := orm.M(&item)
j := norm.NewJoin(mUser).
Auto(mOrder). // → INNER JOIN order ON order.user_id = user.id
AutoLeft(mItem) // → LEFT JOIN order_item ON order_item.order_id = order.id
sql, args, _ := j.Select()
err := pool.QueryRow(ctx, sql, args...).Scan(j.Pointers()...)Auto works in both directions — it finds the FK regardless of which model defines it. Panics if the relationship is ambiguous (multiple FKs to the same table) or missing — use Inner/Left/Right in those cases.
Field names are validated against the model and converted to database column names:
m.OrderBy("Name ASC, Email DESC")
// → "name ASC, email DESC"
sql := fmt.Sprintf("SELECT %s FROM %s ORDER BY %s",
m.Fields(), m.Table(), m.OrderBy("Name DESC"))Accepts field names in any format (struct name, camelCase, snake_case). Direction is optional (defaults to ASC). Panics on unknown fields or invalid direction.
sql := fmt.Sprintf("SELECT %s FROM %s %s",
m.Fields(), m.Table(),
m.LimitOffset(10, 20),
)
// → "SELECT id, name, email FROM users LIMIT 10 OFFSET 20"When your query returns columns not in the struct (e.g. computed columns):
var totalCount int
ptrs := m.Pointers(norm.AddTargets(&totalCount))
// ptrs = [&user.Id, &user.Name, &user.Email, &totalCount]BuildConditions builds WHERE clauses from typed conditions:
m, _ := orm.M(&User{})
// Equality
conds, vals := m.BuildConditions(norm.Eq("name", "John"))
// conds = ["name=$1"], vals = ["John"]
// Comparison operators
conds, vals := m.BuildConditions(
norm.Gte("age", 18),
norm.Lt("age", 65),
)
// conds = ["age >= $1", "age < $2"], vals = [18, 65]
// IN clause
conds, vals := m.BuildConditions(norm.In("name", "Alice", "Bob"))
// conds = ["name IN ($1, $2)"], vals = ["Alice", "Bob"]
// LIKE
conds, vals := m.BuildConditions(norm.Like("name", "%john%"))
// conds = ["name LIKE $1"], vals = ["%john%"]
// IS NULL / IS NOT NULL
conds, vals := m.BuildConditions(norm.IsNull("email", true))
// conds = ["email IS NULL"], vals = []
// With table prefix (for JOINs) — inline or global
conds, vals := m.BuildConditions(norm.Eq("u.name", "John"))
// conds = ["u.name=$1"]
// Global prefix applies to all conditions without inline prefix
conds, vals := m.BuildConditions(norm.Eq("name", "John"), norm.Prefix("u."))
// conds = ["u.name=$1"]
// Mix different prefixes in one call
conds, vals := m.BuildConditions(
norm.Eq("u.name", "John"),
norm.Gte("o.total", 100),
)
// JSON field access
conds, vals := m.BuildConditions(norm.Eq("data->>key", "value"))
// conds = ["data->>key=$1"]
// Combine multiple conditions
conds, vals := m.BuildConditions(
norm.Eq("name", "John"),
norm.Gte("age", 18),
norm.Lt("age", 65),
norm.IsNull("deleted_at", true),
norm.Prefix("u."),
)| Function | SQL | Example |
|---|---|---|
Eq(field, value) |
field = $N |
norm.Eq("name", "John") |
Gt(field, value) |
field > $N |
norm.Gt("age", 18) |
Gte(field, value) |
field >= $N |
norm.Gte("age", 18) |
Lt(field, value) |
field < $N |
norm.Lt("age", 65) |
Lte(field, value) |
field <= $N |
norm.Lte("age", 65) |
Ne(field, value) |
field != $N |
norm.Ne("status", "deleted") |
Like(field, value) |
field LIKE $N |
norm.Like("name", "%john%") |
IsNull(field, bool) |
field IS [NOT] NULL |
norm.IsNull("email", true) |
In(field, values...) |
field IN ($N, ...) |
norm.In("id", 1, 2, 3) |
Prefix(prefix) |
— | norm.Prefix("u.") |
Generate Go structs from an existing database schema. Code generation lives in a separate subpackage:
import (
"database/sql"
"github.com/juggle73/norm/v4/gen"
_ "github.com/lib/pq" // or pgx/stdlib, etc.
)
db, _ := sql.Open("postgres", "postgres://localhost/mydb")
results, err := gen.FromDB(ctx, db, "models", "public")
// results is map[tableName]string with generated Go source code
for tableName, source := range results {
os.WriteFile(tableName+".go", []byte(source), 0644)
}FromDB accepts *sql.DB — any PostgreSQL driver works (lib/pq, pgx/stdlib, etc.).
Generated structs include norm tags (pk, notnull, unique, fk=...) detected from database constraints.
Automatically create and alter tables based on your Go structs. Lives in the migrate subpackage:
import "github.com/juggle73/norm/v4/migrate"
orm := norm.NewNorm(nil)
orm.M(&User{})
orm.M(&Order{})
mig := migrate.New(db, orm)Creates missing tables and adds missing columns. Never drops or modifies existing columns — safe for development:
err := mig.Sync(ctx)Generates a full SQL diff for review — includes CREATE TABLE, ADD/DROP COLUMN, ALTER TYPE, SET/DROP NOT NULL:
sql, err := mig.Diff(ctx)
fmt.Println(sql)
// CREATE TABLE IF NOT EXISTS order (
// id integer NOT NULL,
// user_id integer NOT NULL,
// total integer,
// PRIMARY KEY (id),
// FOREIGN KEY (user_id) REFERENCES user(id)
// );
// ALTER TABLE user ADD COLUMN IF NOT EXISTS age integer;
// ALTER TABLE user DROP COLUMN old_field;Preview the CREATE TABLE statement for any registered model:
fmt.Println(mig.CreateTableSQL("user"))Go types are mapped to PostgreSQL types automatically. Use dbType tag to override:
| Go type | PostgreSQL type |
|---|---|
int |
integer |
int64 |
bigint |
float64 |
double precision |
string |
text (or Config.DefaultString) |
bool |
boolean |
time.Time |
timestamptz |
struct |
jsonb |
map[string]any |
jsonb |
[]byte |
bytea |
| Option | Description | Used by |
|---|---|---|
Exclude("field1,field2") |
Exclude fields by db name | Fields, Binds, UpdateFields, Pointers, Values |
Fields("field1,field2") |
Include only these fields | Fields, Binds, UpdateFields, Pointers, Values |
Prefix("t.") |
Add table alias prefix | Fields |
Returning("field1,field2") |
Fields for RETURNING clause | Insert, Update, Delete |
Limit(n) |
LIMIT value | Select |
Offset(n) |
OFFSET value | Select |
Order("field [ASC|DESC]") |
ORDER BY clause | Select |
AddTargets(&var1, &var2) |
Extra scan targets | Pointers |
Where("field = ?", val) |
WHERE with ? placeholders | Select, Update, Delete |
OnConflict("col").DoNothing() |
ON CONFLICT ... DO NOTHING | Insert |
OnConflict("col").DoUpdate("c1","c2") |
ON CONFLICT ... DO UPDATE SET | Insert |
| Method | Returns | Description |
|---|---|---|
Fields(opts...) |
string |
Comma-separated column names |
Binds(opts...) |
string |
Bind placeholders $1, $2, ... |
UpdateFields(opts...) |
string, int |
SET clause + next bind number |
Pointers(opts...) |
[]any |
Field pointers for Scan |
Values(opts...) |
[]any |
Field values for Exec |
Pointer(name) |
any |
Single field pointer |
Table() |
string |
Table name |
OrderBy(s) |
string |
Validated ORDER BY clause |
Select(opts...) |
string, []any, error |
Full SELECT query + args |
Insert(opts...) |
string, []any, error |
Full INSERT query + values |
Update(opts...) |
string, []any, error |
Full UPDATE query + args |
Delete(opts...) |
string, []any, error |
Full DELETE query + args |
Returning(fields) |
string |
RETURNING clause |
LimitOffset(limit, offset) |
string |
LIMIT/OFFSET clause |
BuildConditions(conds...) |
[]string, []any |
WHERE conditions from typed Cond values |
FieldByName(name) |
*Field, bool |
Find field by any name format |
FieldDescriptions() |
[]*Field |
All field metadata |
NewInstance() |
any |
New zero-value struct pointer |
| Method | Returns | Description |
|---|---|---|
NewJoin(base) |
*Join |
Create join builder with FROM model |
Inner(m, on) |
*Join |
Add INNER JOIN |
Left(m, on) |
*Join |
Add LEFT JOIN |
Right(m, on) |
*Join |
Add RIGHT JOIN |
Auto(m) |
*Join |
INNER JOIN with ON from FK tags |
AutoLeft(m) |
*Join |
LEFT JOIN with ON from FK tags |
Where(s, args...) |
*Join |
Set WHERE clause |
Order(s) |
*Join |
Set ORDER BY (raw SQL) |
Limit(n) |
*Join |
Set LIMIT |
Offset(n) |
*Join |
Set OFFSET |
Select() |
string, []any, error |
Build SELECT query |
Pointers() |
[]any |
Scan targets from all models |
| Method | Returns | Description |
|---|---|---|
NewNorm(config) |
*Norm |
Create instance with optional config |
M(&obj) |
*Model, error |
Get Model bound to struct instance |
AddModel(&obj, table) |
*Model |
Register with explicit table name |
Tables() |
[]string |
All registered table names |
FieldsByTable(table) |
[]*Field |
Field descriptors for a table |
GetConfig() |
*Config |
Current configuration |
| Method | Returns | Description |
|---|---|---|
New(db, orm) |
*Migrate |
Create migrate instance |
Sync(ctx) |
error |
Create tables + add columns (safe) |
Diff(ctx) |
string, error |
Full SQL diff for review |
CreateTableSQL(table) |
string |
Preview CREATE TABLE statement |
SQL generation speed compared to popular query builders. Pure query building, no database involved.
Apple M1 Max, Go 1.23
| Library | ns/op | B/op | allocs/op | vs norm |
|---|---|---|---|---|
| norm | 437 | 376 | 12 | 1.0x |
| squirrel | 2749 | 3025 | 55 | 6.3x slower |
| goqu | 2152 | 3368 | 78 | 4.9x slower |
| Library | ns/op | B/op | allocs/op | vs norm |
|---|---|---|---|---|
| norm | 740 | 416 | 18 | 1.0x |
| squirrel | 2416 | 2425 | 53 | 3.3x slower |
| goqu | 1738 | 2256 | 76 | 2.3x slower |
| Library | ns/op | B/op | allocs/op | vs norm |
|---|---|---|---|---|
| norm | 911 | 608 | 23 | 1.0x |
| squirrel | 3839 | 4138 | 81 | 4.2x slower |
| goqu | 2754 | 3404 | 104 | 3.0x slower |
norm is 3-6x faster with 3-9x fewer memory allocations. The advantage comes from cached struct metadata — reflection happens once per type, then all query building uses pre-computed field lists.
Run benchmarks yourself:
go test -bench=. -benchmem -run=^$M(&obj)returns a Model bound to that specific instance.Pointers(),Values(), andPointer()work with the bound instance without additional parameters.- Model is not safe for concurrent use. Each goroutine should call
M()to get its own Model. - Struct metadata is cached and shared safely across goroutines.
M()returns(*Model, error). Invalid argument (not a pointer to struct) returns an error.OrderBy(),Returning(), andPointer()panic on unknown field names - these are programmer errors.