persistent + esqueleto (Haskell)
The Yesod ecosystem's ORM: persistent turns a whitespace schema block into Haskell entity types and migrations via Template Haskell and gives you type-safe single-table CRUD, while esqueleto layers a type-safe SQL EDSL — the JOINs and complex SELECTs persistent deliberately omits — over the exact same schema.
| Field | Value |
|---|---|
| Language | Haskell (GHC; heavy Template Haskell + QuasiQuotes, GADTs, type families, DataKinds) |
| License | persistent: MIT (LICENSE, persistent.cabal); esqueleto: BSD-3-Clause (LICENSE, esqueleto.cabal license: BSD3) |
| Repository | yesodweb/persistent · bitemyapp/esqueleto |
| Documentation | hackage: persistent · hackage: esqueleto · Yesod book — Persistent chapter |
| Category | Full ORM (data-mapper) — code-first entities + migrations + Key/Entity identity (persistent); typed relational algebra join EDSL (esqueleto) |
| Abstraction level | The full-ORM rung for schema/entities/migration, but without change tracking, unit of work, or lazy loading; esqueleto adds the typed-algebra query rung |
| Query model | TH-declared entities + persistent typed filters ([PersonAge >. 30]); esqueleto typed relational algebra over SqlExpr (a quoted DSL → AST) |
| Effect/async model | SqlPersistT = ReaderT SqlBackend over IO (blocking, MonadUnliftIO); transactions bracket the ReaderT |
| Backends | PostgreSQL, SQLite, MySQL via persistent-*; historically MongoDB, Redis (key-value subset) |
| First release | ≈2012 (web-attested; both LICENSEs dated 2012) |
| Latest version | persistent 2.18.1.0, esqueleto 3.6.0.0 (*.cabal; web-attested for release date) |
NOTE
This pair is the survey's data point for a code-first ORM in a statically-typed, pure-functional ecosystem. persistent owns the schema (it generates entity types and migrations from a Template-Haskell block) and the identity story (Key/Entity), which puts it at the full-ORM rung — but it deliberately drops the mutable-object half of a classic ORM: no change tracking, no unit of work, no lazy load. Its query API is intentionally single-table CRUD, so esqueleto supplies the missing typed relational algebra (JOINs, sub-selects, aggregates) over the same entities. Contrast Beam and Squeal, which unify schema and full queries in one library, and Ecto, whose Changeset adds a validation/tracking layer persistent has no analogue for. Terms below link to concepts.
Overview
What it solves
persistent is, in its own persistent.cabal one-liner, "Type-safe, multi-backend data serialization" (persistent.cabal). Its README frames the ORM label carefully (README.md):
"A Haskell datastore. Datastores are often referred to as "ORM"s. While 'O' traditionally means object, the concept can be generalized as: avoidance of boilerplate serialization. … the ORM concept is a way to make what is usually an un-typed driver type-safe."
The Database.Persist module doc states the backend reach directly (Database/Persist.hs):
"This library intends to provide an easy, flexible, and convenient interface to various data storage backends. Backends include SQL databases, like
mysql,postgresql, andsqlite, as well as NoSQL databases, likemongodbandredis."
Because it is backend-agnostic, persistent cannot offer everything every backend can do — and it says so. Its README names the exact gap that esqueleto exists to fill (README.md):
"Providing a universal query layer will always be limiting. A major limitation for SQL databases is that the persistent library does not directly provide joins. However, you can use Esqueleto with Persistent's serialization to write type-safe SQL queries."
esqueleto picks up there. Its README positions it as the join layer, and its cabal description spells out the division of labour (README.md, esqueleto.cabal):
"Esqueleto is a bare bones, type-safe EDSL for SQL queries that works with unmodified persistent SQL backends. … In particular, esqueleto is the recommended library for type-safe JOINs on persistent SQL backends. (The alternative is using raw SQL, but that's error prone and does not offer any composability.)"
"While
persistentis a nice library for storing and retrieving records, including with filters, it does not try to support some of the features that are specific to SQL backends. In particular,esqueletois the recommended library for type-safeJOINs onpersistentSQL backends."
So the two libraries are read together: persistent owns entities, migrations, identity, and CRUD; esqueleto owns relational queries over those entities. Neither replaces the other, and (per the esqueleto README) "Other than identifier name clashes, esqueleto does not conflict with persistent in any way."
Design philosophy
persistent's guiding ambition is compile-time totality — catch schema and query mistakes in the type-checker, not at runtime (README.md):
"Persistent's goal is to catch every possible error at compile-time, and it comes close to that."
That is why the schema is not a runtime config but a Template-Haskell program that emits the record types, the Key/EntityField GADTs, the PersistEntity instances, and the migration — the compiler then checks every insert, filter, and projection against those generated types.
esqueleto's three stated goals are narrower and SQL-literal (Database/Esqueleto.hs, README.md):
"Be easily translatable to SQL. When you take a look at a
esqueletoquery, you should be able to know exactly how the SQL query will end up. … Support the most widely used SQL features. … Be as type-safe as possible."
Crucially, portability is a non-goal: "It is not a goal to be able to write portable SQL. We do not try to hide the differences between DBMSs from you" (README.md). Where a typed relational-algebra library like Opaleye hides the dialect, esqueleto keeps the generated SQL predictable and lets RDBMS-specific modules (Database.Esqueleto.PostgreSQL, .MySQL, .SQLite) surface engine features.
Connection, pooling & resource lifetime
Both libraries run over persistent's SqlBackend — an open connection plus its prepared-statement cache and the connBegin/connCommit/connRollback hooks. Execution happens inside the SqlPersistT monad, a plain reader over that backend (Database/Persist/Sql/Types.hs):
type SqlPersistT = ReaderT SqlBackend
type SqlPersistM = SqlPersistT (NoLoggingT (ResourceT IO))A pool of connections is a Pool SqlBackend (ConnectionPool), created with withSqlPool/createSqlPoolWithConfig; sizing is a ConnectionPoolConfig with connectionPoolConfigStripes, connectionPoolConfigIdleTimeout, and connectionPoolConfigSize (Database/Persist/Sql/Types.hs). Resource lifetime is handled through resourcet/MonadUnliftIO: withSqlPoolWithConfig brackets the pool with UE.bracket … destroyAllResources (Database/Persist/Sql/Run.hs), so a leaked pool is closed by the bracket rather than a finalizer.
You enter the monad with runSqlPool (lease from the pool) or runSqlConn (a single connection); both wrap the action in a transaction (Database/Persist/Sql/Run.hs):
"Get a connection from the pool, run the given action, and then return the connection to the pool. This function performs the given action in a transaction. If an exception occurs during the action, then the transaction is rolled back."
esqueleto adds no connection or pooling machinery of its own — its select, update, and delete run in the same ReaderT backend m (constrained by SqlBackendCanRead/SqlBackendCanWrite), so they compose inside a runSqlPool/runSqlConn bracket exactly like persistent's own actions.
Query construction & injection safety
This is where the two libraries split most sharply, and where the survey's safe-SQL story lives.
persistent: typed filters over an EntityField GADT. persistent's query API is deliberately small — single-table SELECT/UPDATE/DELETE expressed as lists of Filters and SelectOpts, never joins. A Filter pins a generated EntityField to a value and a comparison operator (Database/Persist/Class/PersistEntity.hs):
data Filter record
= forall typ. (PersistField typ) => Filter
{ filterField :: EntityField record typ
, filterValue :: FilterValue typ
, filterFilter :: PersistFilter
}
| FilterAnd [Filter record]
| FilterOr [Filter record]
| BackendFilter (BackendSpecificFilter (PersistEntityBackend record) record)The comparison combinators are thin constructors of that GADT — every one binds its argument as a FilterValue, never as SQL text (Database/Persist.hs):
infix 4 ==., <., <=., >., >=., !=.
f ==. a = Filter f (FilterValue a) Eq
f >. a = Filter f (FilterValue a) Gt -- likewise >=. Ge, <. Lt, <=. Le, !=. Ne
f <-. a = Filter f (FilterValues a) In -- value in a list
[ ... ] ||. [ ... ] -- OR of two filter listsA query then reads as data — selectList [PersonAge >. 30] [LimitTo 10] — where PersonAge is a constructor of the TH-generated EntityField Person Int GADT, so a typo or a type-mismatched comparison is a compile error. The SelectOpt list carries ordering and paging (Asc/Desc/OffsetBy/LimitTo) (Database/Persist/Class/PersistEntity.hs). Because the value only ever enters as a FilterValue, persistent's filters are injection-safe by construction — there is no string to interpolate into. The escape hatch is rawSql/rawExecute, where you do write SQL text but values still bind as ? placeholders (Database/Persist/Sql/Raw.hs):
"You may put value placeholders (question marks,
?) in your SQL query. These placeholders are then replaced by the values you pass on the second parameter, already correctly escaped. … Since you're giving a raw SQL statement, you don't get any guarantees regarding safety."
rawSql also uses a double-question-mark entity placeholder ??, expanded to the full, correctly-ordered column list for an Entity — the low-level seam persistent uses when the DSL can't express a query (e.g. joins).
esqueleto: a typed SQL EDSL over SqlExpr. esqueleto restores everything the filter API can't express. A query is a SqlQuery (a WriterT accumulating SideData — from-clauses, where-clauses, etc.), and every expression is a typed SqlExpr that renders to a builder plus a list of bound PersistValues (Database/Esqueleto/Internal/Internal.hs):
newtype SqlQuery a = Q { unQ :: W.WriterT SideData (S.State IdentState) a }
data SqlExpr a = ERaw SqlExprMeta (NeedParens -> IdentInfo -> (TLB.Builder, [PersistValue]))The signature surface is small and reads like SQL — where_, on, (^.), and val (Database/Esqueleto/Internal/Internal.hs, README.md):
where_ :: SqlExpr (Value Bool) -> SqlQuery () -- WHERE
on :: SqlExpr (Value Bool) -> SqlQuery () -- ON (join condition)
(^.) :: (PersistEntity val, PersistField typ) -- project a field
=> SqlExpr (Entity val) -> EntityField val typ -> SqlExpr (Value typ)
select $
from $ \p -> do
where_ (p ^. PersonName ==. val "John")
return pTwo mechanisms make this safe. (^.) ("Project a field of an entity") takes a generated EntityField, so a projection can only name a column that exists on that entity, at its real type (Database/Esqueleto/Internal/Internal.hs). And val — the one way a Haskell value enters a query — compiles to a bound parameter, not to SQL text (Database/Esqueleto/Internal/Internal.hs):
-- | Lift a constant value from Haskell-land to the query.
val :: PersistField typ => typ -> SqlExpr (Value typ)
val v = ERaw noMeta $ \_ _ -> ("?", [toPersistValue v])The README's injection section makes the guarantee concrete: a val-lifted string containing '; DROP TABLE foo; -- renders as a single quoted ? parameter and drops nothing (README.md):
"Esqueleto uses parameterization to prevent sql injections on values and arguments on all queries … And the printed value is
hi'; DROP TABLE foo; select 'bye'and no table is dropped. This is good and makes the use of strings values safe."
The loud exception is the unsafeSql* family (unsafeSqlFunction, unsafeSqlValue, unsafeSqlCastAs) for calling functions esqueleto doesn't model. These splice text verbatim, and the README warns that they re-open injection: an unsafeSqlFunction "0; DROP TABLE bar; …"will erase bar, so "never use any user or third party input inside an unsafe function without first parsing it or heavily sanitizing the input" (README.md).
The join story — the whole point. The legacy from takes a lambda whose argument encodes the join shape as nested InnerJoin/LeftOuterJoin types, with on clauses supplied separately (README.md):
select $
from $ \(p `LeftOuterJoin` mb) -> do
on (just (p ^. PersonId) ==. mb ?. BlogPostAuthorId)
orderBy [asc (p ^. PersonName), asc (mb ?. BlogPostTitle)]
return (p, mb)On an outer join the right side may be absent, so mb :: SqlExpr (Maybe (Entity BlogPost)) and you project it with (?.) (nullable) instead of (^.) — the nullability is in the type, and the result comes back as Maybe (Entity BlogPost). The weakness of this form is that a stray or missing on is a runtime error: OnClauseWithoutMatchingJoinException, "thrown whenever on is used to create an ON clause but no matching JOIN is found" (Database/Esqueleto/Internal/Internal.hs).
The Database.Esqueleto.Experimental module (introduced in 3.3.3.0, becoming the default in 4.0.0.0) fixes exactly that by attaching each on to its join with the (:&) pattern (Database/Esqueleto/Experimental.hs):
select $ do
(people :& blogPosts) <-
from $ table @Person
`leftJoin` table @BlogPost
`on` (\(people :& blogPosts) ->
just (people ^. PersonId) ==. blogPosts ?. BlogPostAuthorId)
where_ (people ^. PersonAge >. just (val 18))
pure (people, blogPosts)The module doc states the payoff (Database/Esqueleto/Experimental.hs):
"As a consequence of this, several classes of runtime errors are now caught at compile time. This includes missing 'on' clauses and improper handling of
Maybevalues in outer joins."
The experimental from also enables UNION/UNION ALL/INTERSECT/EXCEPT (union_, unionAll_, intersect_, except_), subqueries in joins, and common table expressions (with, withRecursive) — the "Support the most widely used SQL features" goal made good. Both persistent's OverloadedLabels (p ^. #name) and GHC 9.2's OverloadedRecordDot (person.name) work as terser field projections (README.md).
Schema, migrations & code generation
This is persistent's signature move and the reason it sits at the ORM rung: the schema is code you write once, in a QuasiQuote block, that the compiler expands into everything else.
The schema block. You declare tables in a whitespace DSL inside [persistLowerCase| … |] (or persistUpperCase, or persistFileWith for an external file). The Database.Persist.Quasi module doc defines the syntax (Database/Persist/Quasi.hs):
"This module defines the Persistent entity syntax used in the quasiquoter to generate persistent entities. The basic structure of the syntax looks like this: … You start an entity definition with the table name … followed by a list of fields …
fieldName FieldType. You can indicate that a field is nullable withMaybeat the end of the type."
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
name String
age Int Maybe
deriving Show
BlogPost
title String
authorId PersonId -- a foreign key: PersonId is Person's Key type
deriving Show
|]What Template Haskell generates. mkPersist "Create[s] data types and appropriate 'PersistEntity' instances for the given 'UnboundEntityDef's" (Database/Persist/TH/Internal.hs); share just "Apply[s] the given list of functions to the same EntityDefs" so mkPersist and mkMigrate see one schema. From the block above the compiler emits: the record types (data Person = Person { personName :: String, personAge :: Maybe Int }), the Key Person/PersonId newtype, the EntityField Person typ GADT (PersonName, PersonAge, PersonId) used by both persistent's filters and esqueleto's (^.), and the PersistEntity Person instance carrying the metadata (Database/Persist/Class/PersistEntity.hs):
"Persistent serialized Haskell records to the database. A Database 'Entity' (A row in SQL, a document in MongoDB, etc) corresponds to a 'Key' plus a Haskell record. … For every Haskell record type stored in the database there is a corresponding 'PersistEntity' instance. An instance of PersistEntity contains meta-data for the record."
persistent "automatically generates an ID column for you, if you don't specify one" (Database/Persist/Quasi.hs) — the default Key is a backend auto-increment Int64, overridable with an Id line, a Primary natural/composite key, or a custom type. A generated type-level check, SafeToInsert, even rejects insert on an entity whose primary key has no default, forcing insertKey instead (Database/Persist/Class/PersistEntity.hs).
Auto-migration. mkMigrate "migrateAll" builds a Migration value; runMigration migrateAll diffs the declared schema against the live database and applies CREATE/ALTER. A Migration is a writer stack that accumulates a CautiousMigration = [(Bool, Sql)], where the Bool flags whether a step is unsafe (destructive) (Database/Persist/Sql/Migration.hs):
"A list of SQL operations, marked with a safety flag. If the
BoolisTrue, then the operation is unsafe — it might be destructive, or otherwise not idempotent. If theBoolisFalse, then the operation is safe, and can be run repeatedly without issues."
runMigration runs the safe steps but refuses the dangerous ones (Database/Persist/Sql/Migration.hs):
"Runs a migration. If the migration fails to parse or if any of the migrations are unsafe, then this throws a 'PersistUnsafeMigrationException'."
To apply a destructive change you must opt in with runMigrationUnsafe, or inspect the plan first with showMigration/printMigration. This is strictly code-first: the entity definitions are the single source of truth, migrations are derived, and there is no db-first introspection / codegen (unlike jOOQ/sqlc). The schema DSL is rich — nullability, default=, sqltype=, unique keys, Primary/composite keys, Foreign keys with OnDelete/OnUpdate actions, sum-type entities, and Haddock-style doc comments are all documented in the Quasi module (Database/Persist/Quasi.hs). esqueleto contributes no schema or migration machinery of its own — it consumes persistent's generated EntityField/PersistEntity instances directly, which is what "works with unmodified persistent SQL backends" means.
Type mapping and result decoding
Every stored type is a PersistField, mapping a Haskell value to/from a PersistValue (the backend's tagged cell type) via toPersistValue/fromPersistValue. An entity's row round-trips through toPersistFields :: record -> [PersistValue] and fromPersistValues :: [PersistValue] -> Either Text record (Database/Persist/Class/PersistEntity.hs) — note the Either Text, so a decode failure is a value, surfaced by the runner as a PersistMarshalError. The Haskell↔SQL default mapping (e.g. Text → VARCHAR, Int → INT8/BIGINT, UTCTime → TIMESTAMP, and ZonedTime dropped since persistent 2.0) is tabulated in the TH docs and customizable per column with sqltype= (Database/Persist/Quasi.hs).
Nullability is in the type. A Maybe-suffixed field becomes Maybe a in the record, and on the query side esqueleto tracks it structurally: (^.) yields SqlExpr (Value typ) but (?.) yields SqlExpr (Value (Maybe (Nullable typ))), and an outer-joined table is SqlExpr (Maybe (Entity a)) that select returns as Maybe (Entity a) (Database/Esqueleto/Internal/Internal.hs). just lifts a non-null expression into a nullable one (just (val 18)), so comparing an optional column to a constant is type-checked rather than silently coerced.
Row hydration. persistent's own reads hydrate to Entity record (a Key plus the record) or to the record; esqueleto's select is polymorphic in the return shape via the SqlSelect a r class (Database/Esqueleto/Internal/Internal.hs):
"You may return a
SqlExpr (Entity v)… returned to Haskell-land as justEntity v. … You may return aSqlExpr (Maybe (Entity v))… asMaybe (Entity v). Used forOUTER JOINs. … You may return aSqlExpr (Value t)… asValue t."
SqlSelect's functional dependencies flow type information both ways, which is why "you'll almost never have to give any type signatures for esqueleto queries." There is no ORM-style object graph here: a join returns a tuple of entities, and relations are made explicit in the query — the functional-mapper way to sidestep the N+1 problem rather than lazy-load it.
Effect model, transactions & error handling
This is the dimension the survey weights most, and where persistent+esqueleto differ from the effect-system flagships.
Blocking ReaderT, not an effect value. A database action is a SqlPersistT m a = ReaderT SqlBackend m a, run with runSqlConn/runSqlPool (Database/Persist/Sql/Types.hs, Database/Persist/Sql/Run.hs). It is not a first-class effect description like doobie's ConnectionIO or a ZIO/Effect value; it is a reader over the connection that executes as soon as the surrounding IO runs. Concurrency is ordinary Haskell (MonadUnliftIO, green threads), not a fiber runtime. persistent's actions and esqueleto's queries share this one monad, so they interleave freely:
main :: IO ()
main = runSqlite ":memory:" $ do
runMigration migrateAll -- persistent: auto-migrate
johnId <- insert $ Person "John" (Just 35) -- insert :: record -> ReaderT backend m (Key record)
people <- E.select $ do -- esqueleto: typed query, same monad
p <- E.from $ E.table @Person
E.where_ (p E.^. PersonId E.==. E.val johnId)
pure p
liftIO $ print (people :: [Entity Person])insert returns the freshly-minted Key record (Database/Persist/Class/PersistStore.hs); get :: Key record -> ReaderT backend m (Maybe record) looks a row up by primary key. esqueleto's select :: SqlQuery a -> ReaderT backend m [r] runs inside the same ReaderT (Database/Esqueleto/Internal/Internal.hs).
Transactions bracket the runner, not a combinator. There is no withTransaction block — the transaction boundary is the runSqlConn/runSqlPool call. rawAcquireSqlConn brackets the action with connBegin on acquire and connCommit on normal release / connRollback on exception (Database/Persist/Sql/Run.hs):
"Starts a new transaction on the connection. When the acquired connection is released the transaction is committed and the connection returned to the pool. Upon an exception the transaction is rolled back and the connection destroyed."
Within a transaction you can force a boundary manually: transactionSave "Commit[s] the current transaction and begin[s] a new one", and transactionUndo "Roll[s] back the current transaction and begin[s] a new one. This rolls back to the state of the last call to 'transactionSave' or the enclosing 'runSqlConn' call" (Database/Persist/Sql.hs). Note these are commit/rollback + begin, not SAVEPOINTs — the base persistent API has no nested-transaction / savepoint primitive (individual backends and runSqlPoolWithIsolation add isolation levels, and a savepoint concept lives in backend packages, not the core). Nesting runSqlConn inside runSqlConn opens a second BEGIN on a second connection rather than a savepoint.
Errors are exceptions, not a typed channel. persistent's failures are thrown, not returned: PersistException (PersistError, PersistMarshalError, PersistForeignConstraintUnmet, …) and PersistentSqlException are Exception instances (Database/Persist/Types/Base.hs, Database/Persist/Sql/Types.hs); esqueleto's OnClauseWithoutMatchingJoinException likewise (Database/Esqueleto/Internal/Internal.hs). Only the lower-level conversions return Either Text (fromPersistValues, keyFromValues). So unlike Ecto's {:ok, _}/{:error, _} tuples or doobie's typed error type, the failure set is not reflected in the action's type — you catch SomeException at the edge. This is the conventional Haskell-IO posture, and the main axis on which this pair sits below the effect-system libraries the survey is designed around.
Ecosystem & maturity
persistent is the storage layer of the Yesod web framework and one of the most-depended-upon database libraries on Hackage (web-attested); it is authored by Michael Snoyman and maintained under the yesodweb org, licensed MIT (persistent.cabal, LICENSE dated 2012). The pinned tree is 2.18.1.0 (persistent.cabal, ChangeLog.md). Its backend-agnostic core is realized as sibling packages in the monorepo — persistent-postgresql, persistent-sqlite, persistent-mysql, plus persistent-mongoDB and persistent-redis (the last two filling only the key-value PersistStore portion of the API, not PersistQuery) (README.md). The README is candid that "The MySQL backend is in need of a maintainer" and that MongoDB migrations/composite keys are limited (README.md).
esqueleto was created by Felipe Lessa ("inspired by Scala's Squeryl but created from scratch") and is now maintained under the bitemyapp org, licensed BSD-3-Clause; the pinned tree is 3.6.0.0 (esqueleto.cabal, changelog.md). It works with any unmodified persistent SQL backend and ships RDBMS-specific modules for PostgreSQL, MySQL, and SQLite. Both libraries date to 2012 and are stable, widely-used, production-grade infrastructure in the Haskell web ecosystem (web-attested for adoption).
Strengths
- Compile-time schema safety from one source. A single QuasiQuote block generates entity types,
Key/EntityFieldGADTs,PersistEntityinstances, and migrations — a column typo, a wrong field type, or an unsafeinsert(SafeToInsert) is a compile error (Database/Persist/Quasi.hs,Database/Persist/Class/PersistEntity.hs). - Code-first auto-migration with a safety flag.
runMigrationapplies the derived schema diff and refuses destructive steps by default (CautiousMigration,PersistUnsafeMigrationException) (Database/Persist/Sql/Migration.hs). - Backend-agnostic core. The same entities serialize to PostgreSQL, SQLite, MySQL, or (for the key-value subset) MongoDB/Redis (
Database/Persist.hs,README.md). - Injection-safe by construction on both layers. persistent filters bind values as
FilterValue; esqueleto'svalcompiles to a?parameter; evenrawSqlbinds?placeholders (Database/Persist.hs,Database/Esqueleto/Internal/Internal.hs,README.md). - Full relational power, type-checked. esqueleto adds joins, sub-selects, aggregates,
UNION/EXCEPT, and CTEs, with nullability in the type; the experimental(:&)join syntax moves missing-onand outer-join-Maybebugs to compile time (Database/Esqueleto/Experimental.hs). - Predictable SQL. esqueleto's stated goal is that you can read a query and know the SQL it emits; RDBMS-specific modules expose engine features rather than hiding them (
README.md). - The two compose cleanly. Same schema, same
SqlPersistTmonad, same transaction bracket — mix persistent CRUD and esqueleto queries in one action.
Weaknesses
- No effect value / no typed error channel. Actions are a blocking
ReaderT SqlBackend, and failures are thrownPersistException/OnClauseWithoutMatchingJoinException, not carried in the type — the opposite of doobie/Effect TS (Database/Persist/Types/Base.hs). - persistent's query API is intentionally crippled. Single-table filters only; no joins — you must reach for esqueleto (or
rawSql) for anything relational, by design (README.md). - Not a full ORM despite the schema/migration ownership. No change tracking, no unit of work, no lazy loading; relations are explicit joins returning tuples.
- No savepoints / nested transactions in core.
transactionSave/transactionUndoare commit/begin and rollback/begin, notSAVEPOINTs (Database/Persist/Sql.hs). - Template-Haskell heaviness. The schema is a TH/QuasiQuote program (needs
TemplateHaskell,QuasiQuotes,GADTs,TypeFamilies,DataKinds, …); errors can be opaque, and TH slows compiles. - esqueleto's unsafe hatch re-opens injection.
unsafeSqlFunction/unsafeSqlValuesplice text verbatim — user input there is a live SQL-injection hazard (README.md). - Legacy join syntax is runtime-error-prone. The pre-experimental
from/onthrowsOnClauseWithoutMatchingJoinExceptionat runtime; the experimental module is not yet the default (until4.0.0.0) (Database/Esqueleto/Internal/Internal.hs,Database/Esqueleto/Experimental.hs). - No db-first codegen. Code-first only; there is no introspection/scaffolding from a live database.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Schema as a Template-Haskell QuasiQuote block generating types + migrations | One source of truth; compile-time-checked entities, keys, and filters; auto-migration | TH/QuasiQuote weight; opaque errors; slower compiles; code-first only (no db-first codegen) |
| Backend-agnostic serialization core | Same entities target SQL and NoSQL backends | The universal API can't offer joins/SQL features — a deliberate limitation the README states outright |
Query API = single-table typed filters ([PersonAge >. 30]) | Small, injection-safe, compile-checked CRUD covering the common case | No joins/sub-selects/aggregates in persistent itself; esqueleto (or rawSql) is mandatory for relational work |
| esqueleto as a separate typed EDSL over the same schema | Restores type-safe joins/CTEs/set-ops; composes with persistent unchanged | Two libraries and two query surfaces to learn; identifier clashes need qualified imports |
val / FilterValue bind every value as a ? parameter | Injection impossible for values, on both layers | Escape hatches (rawSql text, esqueleto unsafeSql*) re-expose risk; unsafeSql* splices verbatim |
(:&) experimental join with on attached to each join | Moves missing-on and outer-join-Maybe bugs from runtime to compile time; enables UNION/CTEs | Not the default until 4.0.0.0; legacy from/on still throws OnClauseWithoutMatchingJoinException |
Effect model = blocking ReaderT SqlBackend over IO | Simple, unsurprising; transaction = the runSqlConn/runSqlPool bracket; composes with IO | No effect value, no typed error channel, no savepoints in core — below the effect-system libraries the survey targets |
| Auto-migration refuses unsafe steps unless opted in | Prevents accidental data loss (CautiousMigration safe/unsafe flag) | Destructive changes need runMigrationUnsafe; migrations are derived, not hand-audited by default |
Sources
- yesodweb/persistent — GitHub repository · bitemyapp/esqueleto — GitHub repository
persistent/README.md— "A Haskell datastore", "catch every possible error at compile-time", "does not directly provide joins … use Esqueleto", backend listpersistent.cabal— MIT, synopsis "Type-safe, multi-backend data serialization", version 2.18.1.0 ·ChangeLog.mdDatabase/Persist/Quasi.hs— the schema QuasiQuote syntax, auto-generated ID, nullability, defaults, foreign/composite keysDatabase/Persist/TH/Internal.hs—mkPersist/mkPersistWith,share,mkMigrate/migrateModelsDatabase/Persist/Class/PersistEntity.hs—PersistEntityclass,Key/EntityField/Entity,Filter/FilterValue/SelectOpt,SafeToInsertDatabase/Persist.hs— module doc + the filter/update operators (==.,>.,<-.,||.,=.)Database/Persist/Class/PersistStore.hs—insert/getDatabase/Persist/Sql/Types.hs—SqlPersistT/SqlPersistM,ConnectionPool/ConnectionPoolConfig,PersistentSqlExceptionDatabase/Persist/Sql/Run.hs—runSqlConn/runSqlPool,rawAcquireSqlConntransaction bracket, pool lifetimeDatabase/Persist/Sql/Migration.hs—Migration/CautiousMigration,runMigration,PersistUnsafeMigrationExceptionDatabase/Persist/Sql.hs—transactionSave/transactionUndo·Database/Persist/Sql/Raw.hs—rawSql?/??placeholdersDatabase/Persist/Types/Base.hs—PersistExceptionesqueleto/README.md— "bare bones, type-safe EDSL", "recommended library for type-safe JOINs", injection section, unsafe-function warning, join examplesesqueleto.cabal— BSD3, synopsis + description, version 3.6.0.0 ·changelog.mdDatabase/Esqueleto.hs— top-level EDSL module doc + goalsDatabase/Esqueleto/Experimental.hs— the(:&)join syntax, "runtime errors now caught at compile time", set operations/CTEsDatabase/Esqueleto/Internal/Internal.hs—SqlQuery/SqlExpr,where_/on/(^.)/(?.),val=?parameter,select,OnClauseWithoutMatchingJoinException- Shared vocabulary: SQL & ORM concepts (abstraction ladder · query models · injection · schema & migrations · ORM patterns · N+1 · type mapping · effects & transactions · pools)
- Related deep-dives in this survey (by name; pages may not exist yet):
Beam,Squeal,Opaleye,hasql,doobie,Ecto