Performance
LiteORM is fast because of what it doesn’t do: no reflection on the query hot path, no loading a whole result set when you can stream it, and no per-row round trips when you can insert in bulk. This guide covers the knobs that matter in production — the reflection-free predicate path, streaming with iter.Seq2, bulk insert, connection-pool visibility, and SQLite pragma tuning.
The reflection-free hot path
Section titled “The reflection-free hot path”Typed predicates are the fast path. query.Col[V]("...") operators build SQL through internal/sqlgen without reflecting over your struct at query time — the column set is resolved once per type and cached, and building a WHERE clause is string assembly over that cache, not a walk of struct tags. Row scanning uses a scan plan that is likewise computed once per type and reused, so a tight read loop pays no repeated reflection cost.
// No reflection when building this predicate — it renders "price" > ? directly.hot, err := query.Select[Product](db). Filter(query.Col[float64]("price").Gt(threshold)). OrderBy("price"). All(ctx)The practical guidance: for a genuinely hot query, prefer the query builder’s typed predicates. The declarative orm layer is convenient for CRUD, and you can drop to query on the same Session for the paths that need to be lean — the two front-ends share one core, so mixing them costs nothing (see Query or ORM?).
Stream instead of buffering: iter.Seq2 vs FindInBatches
Section titled “Stream instead of buffering: iter.Seq2 vs FindInBatches”All(ctx) materializes every row into a slice — fine for bounded results, wasteful for a large scan you process one row at a time. The query builder’s Iter terminal returns an iter.Seq2[T, error] that yields rows lazily as they arrive off the wire, so memory stays flat regardless of result size:
for p, err := range query.Select[Product](db).Filter(active).Iter(ctx) { if err != nil { return err } process(p) // one row in flight at a time}The orm repository doesn’t expose Iter directly; for a large scan through the ORM, use FindInBatches, which walks the table by primary-key keyset and hands you one slice per batch (honoring soft-delete and any composed scopes):
repo := orm.NewRepo[Product](db)err := repo.FindInBatches(ctx, 500, func(batch []Product) error { return index(batch) // up to 500 rows per call, bounded memory})| I want… | Use |
|---|---|
| all rows, bounded set | All(ctx) |
stream row-by-row on the query builder |
Iter(ctx) (iter.Seq2) |
process a large scan through the orm repo |
FindInBatches(ctx, n, fn) |
Iter gives you the leanest per-row memory; FindInBatches is the ergonomic ORM-side equivalent when you want to work a page at a time. Both avoid holding the whole result in memory. See streaming & aggregates for the full streaming surface.
Bulk insert
Section titled “Bulk insert”Inserting rows one at a time is a round trip per row. Insert a slice in one shot instead. The query repository’s InsertMany and the orm repository’s CreateInBatches both collapse many rows into few statements:
// query builder — one bulk operation.err := query.NewRepo[Product](db).InsertMany(ctx, products)
// orm — chunked, with hooks and timestamps per row.err = orm.NewRepo[Product](db).CreateInBatches(ctx, ptrs, 1000)On backends without a native bulk path, InsertMany falls back to chunked multi-row VALUES — one INSERT per chunk, sized to stay under the driver’s bind-variable limit — so it is always at least a large win over per-row inserts.
Postgres COPY
Section titled “Postgres COPY”On Postgres, InsertMany uses the native COPY protocol (pgx CopyFrom) automatically — the fastest way to load rows into Postgres, streaming them over a dedicated framing rather than as individual INSERTs. You don’t ask for it: the backend advertises the capability, and the query layer uses it when present and falls back to chunked VALUES when it isn’t. Loading a large batch into Postgres and into SQLite is the same call; only the wire path differs.
Connection-pool visibility
Section titled “Connection-pool visibility”DB.Stats() reports connection-pool health, so you can alarm on saturation or feed the numbers to your metrics:
stats, ok := db.Stats()if ok { log.Printf("open=%d inuse=%d idle=%d waited=%d for %s", stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount, stats.WaitDuration)}The signature is Stats() (PoolStats, bool); the bool is false for a backend whose driver exposes no pool. PoolStats mirrors database/sql.DBStats field-for-field — MaxOpenConnections, OpenConnections, InUse, Idle, WaitCount, WaitDuration, and the idle/lifetime-close counters — and it is reported uniformly whether the backend is database/sql-based (SQLite, MySQL, SQL Server) or the pgx pool (Postgres). A rising WaitCount / WaitDuration means the pool is a bottleneck: raise the max-open limit or shorten the work holding connections. Sample it on an interval; it’s a cheap snapshot, not an event stream.
SQLite pragma and WAL tuning
Section titled “SQLite pragma and WAL tuning”sqlite.Open already applies a production preset: WAL journal mode, a 5-second busy timeout, and foreign-key enforcement (gosqlite.RecommendedPragmas()). WAL is the important one for concurrency — it lets readers proceed while a writer is active, which the default rollback journal does not. For most applications the default is the right choice and needs no tuning.
When you need to tune further — a longer busy timeout under heavy write contention, a relaxed synchronous for a rebuildable cache, a larger page cache, in-memory temp tables — open with sqlite.OpenConfig and a gosqlite.Config:
import gosqlite "gosqlite.org"
db, err := sqlite.OpenConfig(gosqlite.Config{ Path: "app.db", Pragmas: gosqlite.Pragmas{ JournalMode: gosqlite.JournalWAL, BusyTimeout: 10 * time.Second, Synchronous: gosqlite.SynchronousNormal, // WAL-safe, faster than FULL ForeignKeys: true, TempStore: gosqlite.TempStoreMemory, },})Config also carries the pool sizing (MaxOpenConns, MaxIdleConns, ConnMaxLifetime) that feeds the Stats() numbers above. SQLite is a single-writer database: WAL removes reader/writer contention but writes still serialize, so the busy timeout is what keeps a concurrent writer from failing immediately under load — raise it before you reach for anything exotic. See concurrency & locking for the single-writer model and retry patterns.
Keep observability cheap
Section titled “Keep observability cheap”With no observer registered and statement logging above debug, LiteORM allocates no per-statement event and runs the query straight through — observability has zero cost when idle. Turn debug logging on for development and leave it off in production, and reach for an observer only where you want metrics or tracing; both are opt-in and neither is on the default path.
See also
Section titled “See also”- Streaming & aggregates —
Iter,Pluck, and aggregate terminals. - Writing data — the insert / bulk-insert / upsert surface in full.
- Concurrency & locking — SQLite single-writer, retry on contention.
- Observability — the zero-overhead observer seam and pool-stats sampling.