#The trade every portable ORM makes
A portable ORM expresses queries in a language all its backends speak. That subset is much smaller than what any one of them can do, and the interesting parts of every database sit outside it.
Postgres has DISTINCT ON, JSONB containment operators and SELECT FOR UPDATE SKIP LOCKED, which is how you write a queue without a queue. MySQL has upsert on duplicate key and index hints. ClickHouse has PREWHERE and SAMPLE, and using it without them is using it badly. A document store's aggregation pipeline has no equivalent worth pretending to.
So you get portability, and the first time you need a real query you drop to raw SQL, and now the codebase has two access patterns while the ORM covers the easy half.
#What Grove does
A common core handles connections, cached metadata, migrations, hooks and streaming. Each driver exposes a builder speaking its own dialect, and you unwrap to the one for the backend you are on.
1pg := pgdriver.Unwrap(db)23var users []User4err := pg.NewSelect(&users).5 Where("email ILIKE $1", "%@example.com").6 Where("attributes @> $2", filterJSON). // JSONB containment, Postgres only7 OrderExpr("created_at DESC").8 Limit(50).9 Scan(ctx)
This is not portable and does not try to be. Moving from a row store to a column store means rewriting queries, which you were going to do anyway, because a query written for one is the wrong query for the other even when it is syntactically valid.
Portability across databases is a promise most applications never redeem and every ORM charges for.
The module boundary follows the decision being hidden . Connection handling and metadata caching are genuinely common. Query construction is genuinely not, and pretending otherwise is what produces the subset.
#The numbers
Insert benchmark, in-memory SQLite, Go 1.25.7 on arm64, five runs averaged.
- Raw database/sql: 4,015 ns/op, 880 B/op, 20 allocations
- Grove: 4,381 ns/op, 1,283 B/op, 28 allocations, about 9 per cent over raw
- Bun: 8,459 ns/op, 5,470 B/op, 27 allocations, about 111 per cent over raw
- GORM: 10,265 ns/op, 4,954 B/op, 66 allocations, about 156 per cent over raw
The mechanism is that there is no reflection at query time. Struct metadata is resolved once at registration and cached, the query path walks a prepared description, and buffers are pooled. That is available to any ORM willing to give up runtime schema flexibility.
Two caveats worth stating plainly. This is one benchmark on one workload against an in-memory database, which maximises the share of time spent in the driver. Over a network round trip to a real server, all four numbers are noise. It matters on bulk paths and inside a write path where per-command overhead is multiplied by a fan-out.
#The escape hatch that mattered
Grove reads another ORM's struct tags as a fallback when its own are absent, so an existing codebase can adopt it without touching its models. I added it in an afternoon for my own migration and it has done more for adoption than several features that took weeks.
#What I would not claim
This is not a better ORM for a team that wants one query language and does not care what is underneath. That is a legitimate preference. It is for the case where you chose a specific database on purpose, understand its isolation behaviour , and want the ORM to stop standing between you and it .
References
- [1]Martin Kleppmann, “Designing Data-Intensive Applications”, O'Reilly Media, 2017
- [2]Hal Berenson et al., “A Critique of ANSI SQL Isolation Levels”, ACM SIGMOD International Conference on Management of Data, 1995doi:10.1145/223784.223785 ↗
- [3]D. L. Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules”, Communications of the ACM, vol. 15, no. 12, pp. 1053-1058, 1972doi:10.1145/361598.361623 ↗