Comparison & Synthesis
The capstone of the SQL & ORM abstraction survey. It reads the ~30 deep-dives against one another along the survey's fixed five-dimension spine, names the points the whole field agrees on, isolates the trade-offs that are still genuinely open, and closes with where an effects-first sparkles:sql fits.
Last reviewed: July 12, 2026
NOTE
The master catalog already gives the one-line-per-system summary; this page is the cross-cutting analysis. Every claim about a specific library is grounded in its deep-dive; follow the link for the verbatim source citation.
At-a-glance matrix
Each surveyed system by the two axes that most determine its character: how a query is made injection-safe, and what the library does with the effect of running it. The "Compile-time query check?" column is the sharpest differentiator — it separates libraries that catch a bad column or type before the program runs from those that discover it at runtime.
| System | Rung | Injection-safety mechanism | Compile-time query check? | Effect model |
|---|---|---|---|---|
Go database/sql | Driver | Positional placeholders (driver) | No | Blocking (pool) |
postgres.js | Driver + safe-SQL | Tagged template → $n params | No | Async (thenable) |
| Dapper | Micro-mapper | Anonymous-object params → DbParameter | No | Blocking / async |
| JDBI | Micro-mapper | Named/positional bind (:id) | No | Blocking |
| hasql | Micro-mapper | Positional Encoders (out-of-band) | No (opt-in via hasql-th) | Blocking IO |
| Effect TS | Functional data mapper | Tagged template → auto Parameter | No (runtime compile) | Effect value (typed SqlError) |
| doobie | Functional data mapper | sql"..." → Put-bound ? | No (opt-in .check vs live DB) | ConnectionIO (cats-effect) |
| skunk | Functional data mapper | Typed Codec fragments (never interpolates) | No (runtime Describe) | cats-effect + fs2 |
| Ecto | Functional data mapper | Query macros + ^ pin operator | Partial (macro-checked) | Eager, blocking (BEAM) |
| Quill | Functional data mapper | Compile-time quotation → lifts | Yes (static translation) | Pluggable; ZIO |
| Slick | Functional-relational | Lifted embedding (Rep[T]) | Yes (types) | DBIO → effect-poly F |
| Diesel | Typed query builder | Fluent builder → bound params | Yes (types) | Blocking |
| sqlx | Macro-checked raw SQL | Real bind params | Yes (query! vs live DB) | Async |
| sqlc | SQL-to-code generator | Real bind params | Yes (static SQL parse) | Blocking (generated) |
| jOOQ | Typed query builder | Fluent builder → ? bind | Yes (types, from codegen) | Blocking (+ R2DBC) |
| Kysely | Typed query builder | Fluent builder → placeholders | Yes (types, from a DB type) | Async |
| Drizzle | Typed query builder | SQL-like builder → Param | Yes (types, from schema) | Async |
| Exposed | Typed builder + DAO | DSL Op/QueryBuilder params | Yes (types) | Blocking (+ coroutine) |
| Squeal | Typed query builder | Type-level schema + EncodeParams | Yes (type-level) | Indexed PQ monad |
| Opaleye | Typed query builder | Escaped literals (postgresql-simple) | Yes (types) | Blocking IO |
| Beam | Typed data mapper | HKD query DSL → val_ params | Yes (types) | Blocking IO |
| linq2db | LINQ provider | LINQ closure values → params | Partial (C#-typed, runtime SQL) | Async / sync |
| EF Core | Full ORM (data mapper) | LINQ → params | Partial (C#-typed, runtime SQL) | Async |
| Hibernate | Full ORM (data mapper) | JPQL/Criteria named params | Partial (Criteria typed; HQL not) | Blocking (+ reactive) |
| SQLAlchemy | Full ORM + Core | Core expression → bind params | Partial (Core-typed; runtime) | Blocking (+ asyncio) |
| Django ORM | Full ORM (active-record) | QuerySet values → params | No | Blocking (+ async) |
| Prisma | Full ORM (schema-first) | Generated client → params | Yes (from .prisma schema) | Async |
| TypeORM | Full ORM (AR + DM) | Repository/QueryBuilder :param | Partial (entity-typed; runtime) | Async |
| SeaORM | Full ORM (dynamic) | sea-query Value bind | Partial (entity-typed; runtime) | Async |
| GORM | Full ORM (active-record) | clause.Expr ? bind | No (reflection) | Blocking |
| ent | Full ORM (codegen) | Generated typed predicates → params | Yes (from schema codegen) | Blocking |
| persistent + esqueleto | Full ORM + typed joins | TH entities + val params | Yes (types) | Blocking SqlPersistT |
| ActiveRecord | Full ORM (active record) | sanitize_sql bound params | No | Blocking |
Two structural observations fall straight out of the matrix:
- Injection safety is a solved problem, mechanically. Every surveyed library — from the thinnest driver to the heaviest ORM — makes bound parameters the default and reserves a loudly-named escape hatch (
sql.unsafe,$queryRawUnsafe,Arel.sql,sql.raw,FromSqlRaw,Fragment.const) for raw text. The interesting variation is how the safe default is expressed (a tagged template, a builder, a macro, a quotation) — dissected, with D's IES, in the safe-interpolation case study — not whether it exists. - Compile-time query checking is the real fault line. It cleaves the field into three camps: statically-checked (Quill, Diesel, sqlx, sqlc, jOOQ, Kysely, Drizzle, Squeal, Opaleye, Beam, ent, Prisma, esqueleto — a wrong column or type won't compile), runtime-constructed but host-typed (the LINQ/Criteria ORMs — EF Core, Hibernate, SQLAlchemy Core, linq2db — where the host language types the expression but the SQL and its DB-validity are resolved at runtime), and stringly-dynamic (the driver/micro-mapper tier + the reflection ORMs — GORM, Django, ActiveRecord — where nothing checks the SQL against the schema until it executes).
The five-dimension spine
The same analytical cuts every deep-dive uses, read across the whole corpus.
1. Connection, pooling & resource lifetime
The universal substrate. Every non-trivial library ships or wraps a connection pool, because the connect handshake dominates per-query latency. The design variation is in how acquisition and release are scoped:
- Manual / ambient — Go
database/sql's*sql.DBis a pool you pass around; ActiveRecord and Django ORM bind a connection to the current thread/task implicitly; Exposed and Hibernate use a thread-local or context-bound session. Convenient, but a leaked or mis-scoped connection is a runtime bug. - Scoped as a value — the effect systems make lifetime a type. Effect TS's
Acquireris anEffect<Connection, SqlError, Scope>; skunk and doobie hand you aResource[F, …]; Quill's ZIO contexts useZLayer/Scope; Squeal wrapsbracket. Here a leaked connection is a compile-time impossibility — theScope/Resourceguarantees release on every exit path, success or failure. This is the single most transferable idea for an effects-first design.
2. Query construction & injection safety
The survey's richest axis, with six recurring models (concepts). Ranked roughly by how much the compiler knows:
- Raw string (Go
database/sql, Dapper, JDBI SQL objects) — maximum control, zero static knowledge. - Tagged template (Effect TS, postgres.js, doobie) — reads like string interpolation, compiles to a parameterized statement.
postgres.jsis the most elegant point in this space: the samesqlfunction is the query tag and the fragment builder (sql(obj)for inserts,sql(arr)forIN,sql('col')for identifiers). - Macro-checked raw SQL (sqlx, sqlc) — you still write SQL, but a build step validates it and infers result types. sqlx checks against a live DB (or a committed
.sqlxcache); sqlc parses the SQL statically with an embedded real grammar (libpg_query), needing no DB connection. - Fluent typed builder (jOOQ, Kysely, Diesel, Drizzle, Exposed, SeaORM) — you never write SQL text; a method chain typed by a schema (generated, or a supplied type) produces it. Kysely is the purest "type-only" form (the schema is just a TypeScript type, erased at runtime).
- Typed relational algebra (Slick, Squeal, Opaleye, Beam, esqueleto) — the query is a value in an embedded algebra whose types encode the schema; the Haskell trio pushes this furthest (Squeal reifies the entire schema as a type; Beam's higher-kinded data reuses one record for values and query expressions).
- Quoted DSL / LINQ → AST (Quill, EF Core, linq2db) — a macro or expression-tree capture turns ordinary host-language code into an AST, then SQL. Quill is the compile-time extreme (SQL is generated during compilation where possible).
The escape hatch is universal and universally dangerous; the mark of a good design is how rarely you need it and how loudly it announces itself.
3. Schema, migrations & code generation
Three stances (concepts), and a clean split on who owns the truth:
- Code-first (models → schema): EF Core, Django ORM, Prisma (schema-file-first, really), TypeORM, Beam, Drizzle, Ecto, GORM, ent, Exposed. The ORM emits migrations from the model.
- Db-first (introspect → generate): jOOQ, sqlc, sqlx, Diesel, Kysely (via codegen), Opaleye, ActiveRecord (
schema.rb). The database is the truth; typed code is generated from it. - Schema-agnostic (no schema at all): the driver/micro-mapper tier (doobie, skunk, hasql, Dapper, JDBI, Go
database/sql, postgres.js) — you bring your own schema and migration tool.
A recurring, important finding: migrations are frequently not the query library's job.SQLAlchemy delegates to Alembic; Hibernate to Flyway/Liquibase; ent to Atlas; sqlc reads a schema but runs no migrations; the whole functional-mapper tier omits them by design. A migration runner is a separable concern.
4. Type mapping & result decoding
How a row becomes a typed value, and how much of that mapping is checked. The elegant designs make the codec composable and first-class: skunk's Codec and hasql's Encoders/Decoders compose bidirectionally like parser combinators; Quill derives GenericEncoder/GenericDecoder at compile time; the Haskell profunctor libraries (Opaleye) drive both binding and decoding from one Default instance. The ORMs hydrate whole object graphs (and pay for it with lazy-loading and N+1 hazards — see below). The micro-mappers (Dapper IL-emits a per-shape materializer; JDBI's RowMappers; sqlx/sqlc generate the struct) sit in between: typed row→object mapping without graph management.
5. Effect model, transactions & error handling
The dimension that most divides the field, and the one this survey weights most heavily.
- Effect model. Three tiers: blocking (the JVM/Go/Ruby/Python mainstream + the Haskell
IOlibraries), async (the .NET/TS/Rust mainstream — futures/Task/Promises), and effect value (the description-as-a-value tier: Effect TS'sEffect, Quill's ZIO, doobie/skunk's cats-effectF, Slick's effect-polymorphicDBIO, Squeal's indexedPQ). Only the last makes the required environment and the possible errors part of the query's type. - Transactions. Near-universal shape: a combinator wrapping a block (
withTransaction(effect),transaction { … },sql.begin(fn),inTransaction), committing on success and rolling back on failure. Nesting via savepoints is common but not universal — Effect TS, Quill, EF Core, Hibernate, jOOQ, SQLAlchemy, Prisma, postgres.js, Django ORM emit realSAVEPOINTs for nested transactions, while ent (returnsErrTxStarted), Slick, and linq2db (one flat transaction per connection) do not. - Errors. Three approaches, in ascending order of type-safety: thrown exceptions (the mainstream —
SQLException,DbException,HibernateException,PersistException, Prisma'sP####codes), a narrowed exception channel (Quill'sZIO[…, SQLException, …], doobie'sMonadError[F, Throwable]), and a structured typed error union — the design frontier, held essentially alone by Effect TS'sSqlErrorover an 11-caseSqlErrorReasondiscriminated union, each case carrying anisRetryableflag. No other surveyed library models "unique violation vs deadlock vs serialization failure" as distinct, matchable, retryable-aware types.
The consensus standard
Points on which the entire field — thin to heavy, across a dozen languages — has converged. A sparkles:sql that violated any of these would be objectively behind the state of the art:
- Parameter binding is the default; raw text is an opt-in escape hatch. Non-negotiable.
- Connections are pooled, and the pool is safe for concurrent use.
- Transactions are a block-scoped combinator that commits on success and rolls back on failure/exception.
- Result rows map to typed values — even the thinnest mappers (Dapper, sqlx, JDBI) refuse to leave you with untyped column bags.
- Dialect differences are abstracted behind a compiler/
Idiom/Dialect, so one query targets many engines. - Migrations are a separable concern — increasingly delegated to a dedicated tool (Alembic, Flyway, Atlas) rather than baked into the query layer.
Architectural trade-offs (still genuinely open)
Where the field has not converged — the real design decisions:
| Trade-off | The two poles | Where the survey lands |
|---|---|---|
| Compile-time vs runtime query checking | Static (Quill, sqlx, Diesel, the Haskell trio) vs runtime (EF Core, GORM) | Static catches more bugs but constrains expressiveness and lengthens builds; the strongest static designs pay in type complexity. |
| Write-SQL vs build-SQL | Raw SQL, checked (sqlx, sqlc, doobie) vs a typed builder/DSL (jOOQ, Diesel, Slick) | No winner. Raw-SQL-checked keeps SQL legible and skips a DSL; builders compose and retarget dialects. Both beat stringly-dynamic. |
| Explicit persistence vs unit of work | Explicit writes (Ecto, doobie, the functional tier) vs implicit flush (Hibernate, EF Core, SQLAlchemy) | The unit of work is powerful but the source of ORMs' hardest bugs (flush timing, lazy-load N+1, LazyInitializationException). The functional mappers reject it deliberately. |
| Effect value vs async vs blocking | Effect value (Effect TS, Quill, cats-effect) vs Promise/Task vs blocking | Effect values make environment + errors typed and composition lawful, at the cost of a runtime and a learning curve. |
| Typed errors vs exceptions | A reason union (Effect TS) vs thrown SQLException | The typed-union frontier is essentially unoccupied; it is where the most design headroom remains. |
| Schema ownership | Code-first vs db-first vs none | Follows the product: greenfield favours code-first migrations; integrating-an-existing-DB favours db-first codegen. |
Where an effects-first sparkles:sql fits
The survey exists to inform an algebraic effects-first D library. Read as a design brief, the corpus points to a clear position: a functional data mapper that stops below the full-ORM rung — typed, composable queries and explicit, effect-typed persistence, without an identity map or an implicit unit of work. That is exactly where Effect TS, Quill, doobie, skunk, and Ecto sit, and it is the design centre the effects-first premise most naturally serves.
The most transferable ideas, by dimension:
- The service seam = the query constructor. Effect TS's sharpest move is that the injected
SqlClientservice is thesqltagged-template function. A D analogue: the capability handed in by the effect layer is itself the query builder, so obtaining "the database" and "the way to write a query" are one act. Pairs naturally with D's Design-by-Introspection capability-detection style. - Scoped acquisition as the resource discipline. Model the connection/pool as a scoped acquire/release (Effect TS's
Acquirer, skunk'sResource), so a leaked connection is unrepresentable. D'sscope/@safe/-preview=dip1000lifetime checking and the repo'sduring/event-horizoneffect substrate are the natural fit. - A typed error reason union, not a bag of exceptions. Effect TS's
SqlErrorReason(unique-violation / deadlock / serialization-failure / … each withisRetryable) is the single most under-adopted idea in the field and the best match for D'sExpected!(T, E)error handling — asum-typed error channel where a caller can match on "was this a retryable serialization failure?". - Transaction-as-combinator with a fiber/context-local connection. The universal
withTransaction(effect)shape, with nesting lowered to realSAVEPOINTs and the active connection carried in the effect context (as Effect TS'sTransactionConnectionand Quill'sFiberRef[Connection]do) — so nested calls and batched resolvers transparently join the same transaction. - Safe query construction: a compile-time-checked template, not a builder. Between Quill's compile-time quotation and Effect's runtime tagged-template, D can aim higher than either: D's
mixin+ CTFE + template machinery can make a compile-time-parameterized SQL template (values captured as binds, dialect rendered at compile time) that is injection-safe by construction without an ORM DSL — closer to Quill's static translation but using D string-interpolation/IES rather than a macro. Keep Quill's cleanIdiom(dialect) /NamingStrategyseparation. - Composable codecs. Adopt skunk/hasql's bidirectional, combinator-composed
Codecfor row↔type mapping,@nogc-friendly and derivable via D introspection — not a reflection-driven ORM materializer. - Batching without an ORM. Effect TS's
SqlResolver(DataLoader-style, transaction-keyed) shows how to kill N+1 without lazy proxies — the functional answer to the ORM's hardest problem.
What to not borrow: the implicit unit of work, the identity map, lazy-loading proxies, and ambient/thread-local session state — the machinery that makes Hibernate/EF Core/ActiveRecord powerful but is also the source of their subtlest bugs, and which an effects-first, explicit-by-design library is precisely positioned to avoid.
NOTE
This synthesis is prior-art analysis, not a committed design. A concrete sparkles:sql proposal — milestones, D API sketches, the during/event-horizon integration — is deferred to a later spec effort, for which this survey is the evidence base.
Sources
- Every claim about a specific library is grounded in its deep-dive (linked above), each of which carries its own primary-source
Sourcessection and a claim-by-claim grounding ledger. - The shared vocabulary and the canonical pattern references (Codd; Fowler's Patterns of Enterprise Application Architecture; OWASP on SQL injection) are cited in concepts.