Squeal (Haskell / PostgreSQL)
A deeply-typed PostgreSQL EDSL for Haskell in which the database schema is a type: tables, columns, nullability, and constraints are encoded at the type level, queries and DML are type-checked against that schema, and the session monad is indexed by the schema before and after — so a migration literally changes the type.
| Field | Value |
|---|---|
| Language | Haskell (GHC; leans hard on DataKinds, TypeFamilies, GADTs, PolyKinds, OverloadedLabels) |
| License | BSD-3-Clause — squeal-postgresql/LICENSE (© 2017 Morphism, LLC), license: BSD-3-Clause in squeal-postgresql.cabal |
| Repository | morphismtech/squeal |
| Documentation | Hackage haddocks · Stackage · in-repo Core Concepts Handbook |
| Category | Typed query builder with a type-level Postgres schema (shading into a functional data mapper) |
| Abstraction level | Typed query builder / functional data mapper — above a driver, below a full ORM (ladder) |
| Query model | Typed relational algebra over a type-level schema / phantom types (Query/Expression/Manipulation) |
| Effect/async model | The PQ indexed monad transformer over IO — an Atkey indexed monad whose two schema indices track schema change (effects) |
| Backends | PostgreSQL only — built directly on postgresql-libpq + postgresql-binary (not hasql) |
| First release | ≈2017 (web-soft; LICENSE © 2017) |
| Latest version | 0.9.2.0 (squeal-postgresql.cabal); date web-soft |
NOTE
Squeal is this survey's data point for the deep-static extreme of the typed-query-builder rung. Where jOOQ/Kysely/Diesel type-check column and result shapes, Squeal reifies the whole Postgres schema — every table, column, nullability flag, and constraint — as a type-level value, and threads that schema through an indexed monad so a CREATE/ALTER/DROP is a change of type. It is the closest Haskell sibling to Opaleye and Beam on the construction axis, and it parallels hasql's Statement profunctor for encode/decode while building on libpq rather than on hasql.
Overview
What it solves
Squeal is a full embedding of PostgreSQL's SQL surface — not just SELECT, but the data-manipulation and data-definition languages too. Its README enumerates the scope (README.md): "Squeal embeds not just the structured query language of SQL but also the data manipulation language and the data definition language; that's SELECT, INSERT, UPDATE, DELETE, WITH, CREATE, DROP, and ALTER commands." Every one of those commands is a Haskell value whose type records exactly which schema it targets and what it produces.
The point of that machinery is a query that cannot lie about the database. A SELECT that names a column the table does not have, compares two incompatible Postgres types, or forgets to GROUP BY before aggregating is a compile error, not a runtime SQLException. The Core Concepts Handbook frames the whole library as a handful of familiar SQL-shaped types carrying unfamiliar type parameters (squeal-core-concepts-handbook.md):
"At its core, you can view Squeal as a small group of easy-to-understand types (
Query,Manipulation,Statement,Expression,FromClause, andTableExpression) that have hard-to-understand type parameters (Expression grouping lat with db params from ty). The former map to your existing understanding of SQL in a fairly obvious way; the latter make sure that your queries are actually valid."
Design philosophy
Squeal's defining commitment, in its author's own words, is a deep embedding at both levels of the language (README.md):
"Squeal is a deep embedding of SQL into Haskell. By "deep embedding", I am abusing the term somewhat. What I mean is that Squeal embeds both SQL terms and SQL types into Haskell at the term and type levels respectively. This leads to a very high level of type-safety in Squeal."
The second commitment is predictable SQL. Squeal is not an optimizer or a query planner; a combinator renders to the SQL you would expect, and nothing rewrites it (README.md): "Squeal expressions closely match their corresponding SQL expressions so that the SQL they actually generate is completely predictable. They are also highly composable and cover a large portion of SQL." The README demonstrates this by round-tripping a createTable through printSQL and observing the output is "unsurprising looking" (README.md). This is a real architectural choice: unlike Slick's multi-phase query compiler, Squeal has no reified AST and no rendering pipeline — each Query/Expression/Manipulation is a phantom-typed wrapper around an already-rendered ByteString, and all the safety lives in the type parameters (see Query construction).
The trade-off Squeal accepts for this is verbosity and type complexity, and it says so. A community presentation puts the bargain bluntly (squeal-presentation-raveline.md): "you need verbosity to get type safety." The handbook agrees the type parameters are "the most complicated part of learning to use Squeal" (squeal-core-concepts-handbook.md).
Connection, pooling & resource lifetime
A Squeal application runs inside PQ over a single libpq connection. The lowest-level runner is withConnection, a bracket around connectdb/finish (Session.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session.hs
withConnection
:: forall db0 db1 io x. (MonadIO io, MonadMask io)
=> ByteString
-> PQ db0 db1 io x
-> io x
withConnection connString action =
unK <$> bracket (connectdb connString) finish (unPQ action)Because the acquire/release is a bracket, a leaked connection is prevented structurally rather than by a finally a caller might forget. For concurrent workloads, Squeal.PostgreSQL.Session.Pool wraps the resource-pool library. createConnectionPool builds a striped pool keyed on the schema (Pool (K Connection db)), and usingConnectionPool leases one connection for a PQ db db io x session, masking exceptions so a broken connection is destroyed rather than returned (Session/Pool.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session/Pool.hs
usingConnectionPool
:: (MonadIO io, MonadMask io)
=> Pool (K Connection db) -- ^ pool
-> PQ db db io x -- ^ session
-> io x
usingConnectionPool pool (PQ session) = mask $ \restore -> do
(conn, local) <- liftIO $ takeResource pool
ret <- restore (session conn) `onException`
liftIO (destroyResource pool local conn)
liftIO $ putResource local conn
return $ unK retThe pool parameters are explicit — stripe count, idle timeout (NominalDiffTime), and max connections per stripe — and the docstring recommends an explicit destroyConnectionPool rather than relying on the GC to reap idle connections (Session/Pool.hs). This maps onto the survey's scoped acquire/release discipline, though — unlike Effect TS's Scope or Slick's CE3 Resource — Squeal's lifetime story is bracket/mask over MonadMask, not a first-class scoped resource value.
Query construction & injection safety
The schema is a type
Everything in Squeal is checked against a type-level Postgres schema. The encoding is a tower of promoted datakinds defined in Squeal.PostgreSQL.Type.Schema. At the bottom is PGType, the "promoted datakind of PostgreSQL types" (Type/Schema.hs) — PGbool, PGint4, PGtext, PGtimestamptz, PGvararray, PGcomposite, and so on. A NullType wraps a PGType with its nullability, which the docstring explains "encodes the potential presence or definite absence of a NULL allowing operations which are sensitive to such to be well typed" (Type/Schema.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Type/Schema.hs
data NullType
= Null PGType -- ^ NULL may be present
| NotNull PGType -- ^ NULL is absent
type ColumnType = (Optionality, NullType) -- DEFAULT-ness + null-ness + base type
type ColumnsType = [(Symbol, ColumnType)] -- a row of named columns
type TableType = (TableConstraints, ColumnsType) -- constraints + columns
data SchemumType = Table TableType | View RowType | Typedef PGType | Index IndexType | …
type SchemaType = [(Symbol, SchemumType)] -- a named schema's objects
type SchemasType = [(Symbol, SchemaType)] -- the whole databaseThese are glued together by two type operators. ::: pairs an alias Symbol with a type ("intended to connote Haskell's ::") and :=> pairs a constraint with a type ("intended to connote Haskell's =>") (README.md). A complete database schema is therefore an ordinary — if verbose — Haskell type, written with DataKinds promotion (README.md):
-- squeal-postgresql/README.md
type UsersColumns =
'[ "id" ::: 'Def :=> 'NotNull 'PGint4
, "name" ::: 'NoDef :=> 'NotNull 'PGtext ]
type UsersConstraints = '[ "pk_users" ::: 'PrimaryKey '["id"] ]
type EmailsColumns =
'[ "id" ::: 'Def :=> 'NotNull 'PGint4
, "user_id" ::: 'NoDef :=> 'NotNull 'PGint4
, "email" ::: 'NoDef :=> 'Null 'PGtext ]
type EmailsConstraints =
'[ "pk_emails" ::: 'PrimaryKey '["id"]
, "fk_user_id" ::: 'ForeignKey '["user_id"] "public" "users" '["id"] ]
type Schema =
'[ "users" ::: 'Table (UsersConstraints :=> UsersColumns)
, "emails" ::: 'Table (EmailsConstraints :=> EmailsColumns) ]
type DB = Public SchemaThe type family Public lifts a single schema into the one-schema "public" database, and Squeal supports multi-schema databases directly. Type families over these kinds do the schema arithmetic: Create, Drop, Alter, and Rename add, remove, and change entries (raising a custom TypeError on a duplicate or missing alias), while TableToRow, NullifyRow, and friends compute result-row shapes and outer-join nullification (Type/Schema.hs).
Queries are phantom-typed, not reified
The query DSL is a family of newtypes over a rendered ByteString, each carrying the schema in its type parameters. Query has five (Query.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Query.hs
newtype Query
(lat :: FromType) -- lateral scope for correlated subqueries
(with :: FromType) -- common-table-expression scope
(db :: SchemasType) -- the database this query is checked against
(params :: [NullType]) -- out-of-line parameter types
(row :: RowType) -- the result row
= UnsafeQuery { renderQuery :: ByteString }Expression carries seven parameters (adding a Grouping phantom and the current from-clause scope), Manipulation four, and Definition two schema indices (Expression.hs, Manipulation.hs, Definition.hs). Crucially, the payload is already-rendered SQL — there is no intermediate AST type that a compiler walks. Correctness is entirely a property of the phantom parameters: when you write #users ! #id, the type checker consults the from/db scope to prove the column exists, and the handbook notes "it's this scope inside the from type variable that Squeal checks to ensure that the reference is valid" (squeal-core-concepts-handbook.md). A reference to a missing column, or an aggregate used without groupBy, simply fails to type-check (the handbook walks through both errors verbatim).
The combinators mirror SQL clause-for-clause. select/select_, from, where_, innerJoin, groupBy, having, orderBy, limit, offset, union/intersect/except, with (CTEs), window functions, and correlated subqueries are all present. Overloaded labels (#users, #id) name tables and columns, .==/.>/.&& build typed conditions, and `as` aliases. From the README, a typed inner join and the SQL it renders to (README.md):
-- squeal-postgresql/README.md
getUsers :: Statement DB () User
getUsers = query $ select_
(#u ! #name `as` #userName :* #e ! #email `as` #userEmail)
( from (table (#users `as` #u)
& innerJoin (table (#emails `as` #e))
(#u ! #id .== #e ! #user_id)) )
-- SELECT "u"."name" AS "userName", "e"."email" AS "userEmail"
-- FROM "users" AS "u" INNER JOIN "emails" AS "e" ON ("u"."id" = "e"."user_id")Injection safety: parameters bind out-of-line
User data never enters the query text. Values are supplied as out-of-line parameters through param @n, which renders a positional placeholder (with a type annotation) and leaves the actual value to be sent on a separate channel (Query.hs):
-- a parameterized query renders a placeholder, not the value
select Star (from (table #tab) & where_ (#col1 .> param @1))
-- SELECT * FROM "tab" AS "tab" WHERE ("col1" > ($1 :: int4))At execution, the parameter is encoded by an EncodeParams and handed to LibPQ.execParams in binary format, entirely out of band from the SQL string (Session.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session.hs (executeParams, abridged)
encodedParams <- runReaderT (runEncodeParams encode x) kconn
formattedParams <- … -- [(Oid, ByteString, Format)] carrying oid + binary bytes
resultMaybe <- LibPQ.execParams conn (q <> ";") formattedParams LibPQ.BinaryBecause the query text and the data travel on different channels — exactly the prepared-statement safety mechanism — SQL injection is structurally impossible in the typed API; there is no place to concatenate a value into SQL. The escape hatches are the Unsafe* constructors (UnsafeQuery, UnsafeExpression, UnsafeManipulation, UnsafeDefinition, UnsafePGType) and helpers like unsafeFunction/unsafeBinaryOp, which splice raw ByteString text — the one place a user can reintroduce injection risk, and used internally for constructs Squeal does not model (e.g. UnsafeManipulation "SET client_min_messages TO WARNING"). They are named Unsafe precisely so their use is visible in review.
Schema, migrations & code generation
Definitions change the schema type
DDL is a Definition db0 db1 — a value witnessing a change from schema db0 to schema db1. It is a Category, so definitions compose with >>>, and the README fixes the mental model (README.md): "a Definition like createTable, alterTable or dropTable has two type parameters, corresponding to the schema before being run and the schema after. We can compose definitions using >>>." A createTable therefore has a type that proves it produces exactly the new schema (Definition.hs, README.md):
-- squeal-postgresql/README.md
setup :: Definition (Public '[]) DB
setup =
createTable #users
( serial `as` #id :* (text & notNullable) `as` #name )
( primaryKey #id `as` #pk_users ) >>>
createTable #emails
( serial `as` #id :* (int & notNullable) `as` #user_id :* (text & nullable) `as` #email )
( primaryKey #id `as` #pk_emails :*
foreignKey #user_id #users #id (OnDelete Cascade) (OnUpdate Cascade) `as` #fk_user_id )setup starts from the empty public schema Public '[] and ends at DB; teardown :: Definition DB (Public '[]) runs it in reverse. Getting the target type wrong is a compile error, so a migration and its rollback are checked to be genuine inverses at the type level.
Migrations are schema-changing and type-tracked
Squeal.PostgreSQL.Session.Migration exists "to safely change the schema of your database over time" (Session/Migration.hs). A Migration def db0 db1 bundles a unique name with a definition, and a Path of migrations chains them so each migration's output schema is the next one's input. The Migratory class comes in four flavours captured by the module's own docstring (Session/Migration.hs):
"
Migrations are parameterized giving the option of a ... pure one-wayMigrationDefinition... impure one-wayMigration(Indexed PQ IO)... pure reversibleMigration(IsoQ Definition)... impure reversibleMigration(IsoQ (Indexed PQ IO))."
The "reversible" (IsoQ) flavour pairs an up and a down definition, giving migrateUp and migrateDown; the "impure" flavours run arbitrary IO (data backfills) inside the indexed monad rather than pure SQL. A bookkeeping table records what has run — MigrationsTable has a unique name and a DEFAULT-ed executed_at timestamp, created with createTableIfNotExists, and runMigrations runs the whole path transactionally_, inserting a row per applied migration and skipping any already present (Session/Migration.hs). mainMigrate/mainMigrateIso wrap this into a CLI executable with migrate/rollback/status subcommands.
No code generation: schema is hand-written, code-first
Squeal is emphatically code-first: the schema is a Haskell type you write by hand, and there is no introspection/codegen path in the surveyed tree — nothing analogous to jOOQ, sqlc, or Slick's slick-codegen that reads a live database and emits typed schema code. This is a real trade-off (an absence worth naming): the type-level schema and the actual database can drift, and keeping a large schema type in sync with production DDL is manual labour. Squeal's answer is the opposite direction — its Definitions are the DDL, so printSQL setup emits the CREATE TABLE statements, and the migration runner applies them — but the type is still the source of truth a human must author. (The result-side of decoding is generic: Haskell record types map to rows via generics-sop, below.)
Type mapping & result decoding
The bridge between Haskell types and Postgres types is the IsPG class with an associated PG type family (Type/PG.hs): instance IsPG Bool where type PG Bool = 'PGbool, PG Int32 = 'PGint4, PG Text = 'PGtext, and so on — an open relationship a user extends for their own newtypes. Encoding and decoding are a matched pair of first-class, composable values, and the Statement type bundles them with the query (Session/Statement.hs): "A top-level Statement type wraps a Squeal.PostgreSQL.Query.Query or Squeal.PostgreSQL.Manipulation.Manipulation together with an EncodeParams and a DecodeRow."
EncodeParams db tys xturns a Haskell inputxinto a heterogeneous list of binary encodings; it is aContravariantfunctor, solmap/contramapadapt the parameter type (Session/Encode.hs).DecodeRow row yis aReaderTover the raw row bytes inExcept Text, derivingMonad,Alternative,MonadError, andIsLabel— so a decoder is written monadically and can fail with a typed decoding error (Session/Decode.hs).- Together, a
Statementis aProfunctor(lmapover params,rmapover rows), a design the release notes credit tohasql(RELEASE NOTES.md): "TheStatementProfunctoris heavily influenced by theStatementProfunctorfrom Nikita Volkov's excellenthasqllibrary, building on the use ofpostgresql-binaryfor encoding and decoding."
Most users never write encoders/decoders by hand: GenericParams and GenericRow derive them for any generics-sop product type (Session/Encode.hs, Session/Statement.hs). The smart constructors query/manipulation call genericParams/genericRow, so a Statement DB User () encodes a Haskell User record into the right parameters and decodes result rows back into records — the User in the README derives SOP.Generic and SOP.HasDatatypeInfo and nothing more (README.md).
Nullability is in the types, end to end. A 'Null 'PGtext column decodes to Maybe Text and a 'NotNull 'PGtext to Text; the NullType phantom propagates through expressions (just_, fromNull, Option-aware operators), so a nullable column that is treated as non-null is a type error, matching the survey's nullability axis.
Effect model, transactions & error handling
The PQ indexed monad
The signature feature — unusual even among typed libraries — is that Squeal's session type is an Atkey indexed monad transformer parameterized by the schema before and after. The Session module fixes the usage (Session.hs): "Using Squeal in your application will come down to defining the DB :: SchemasType of your database and including PQ DB DB in your application's monad transformer stack, giving it an instance of MonadPQ DB." The type itself (Session.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session.hs
-- | We keep track of the schema via an Atkey indexed state monad transformer, PQ.
newtype PQ
(db0 :: SchemasType) -- schema before
(db1 :: SchemasType) -- schema after
(m :: Type -> Type)
(x :: Type) =
PQ { unPQ :: K LibPQ.Connection db0 -> m (K x db1) }The abstraction is generalized in Squeal.PostgreSQL.Session.Indexed, whose IndexedMonadTrans class documents the theory (Session/Indexed.hs):
"An [Atkey indexed monad] ... is a
Functor[enriched category]. An indexed monad transformer transforms aMonadinto an indexed monad, and is a monad transformer when its source and target are the same, enabling use of standarddonotation for endo-index operations."
That last clause is the ergonomic payoff. When the schema does not change (db0 ~ db1), PQ db db m is an ordinary Monad, so plain do-notation works for queries and DML. When it does change — a migration — you sequence with the indexed combinators (pqThen, pqBind, &), and define :: Definition db0 db1 -> pq db0 db1 io () lifts a schema-changing DDL into the indexed monad. The README's end-to-end program threads a changing schema this way (README.md): "We can thread the changing schema information through by using the indexed PQ monad transformer and when the schema doesn't change we can use Monad and MonadPQ functionality." This is a very strong static guarantee: the type of a session records the schema it started and ended in, so you cannot run a query against a table a prior migration has not yet created, nor forget to update the schema after an ALTER.
Running statements: MonadPQ
Statements run through an mtl-style class, MonadPQ, "similar to Control.Monad.State.Class.MonadState, for using Database.PostgreSQL.LibPQ to run Statements" (Session/Monad.hs). Its core method is executeParams, with conveniences layered on top (Session/Monad.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session/Monad.hs
class Monad pq => MonadPQ db pq | pq -> db where
executeParams :: Statement db x y -> x -> pq (Result y)
execute :: Statement db () y -> pq (Result y) -- parameter-free
prepare :: Statement db x y -> pq (Prepared pq x (Result y))
-- and derived helpers:
-- manipulateParams / manipulateParams_ (INSERT/UPDATE/DELETE with params)
-- runQueryParams / runQuery (SELECT)
-- executePrepared / executePrepared_ (prepare once, run over a Traversable)Result y is then drained with getRows, firstRow, or ntuples. MonadPQ is instanced for PQ db db io (schema-preserving) and lifts through the standard mtl transformers, so a real app stack gets the API for free (Session/Monad.hs). Prepared statements are first-class: prepare returns a Prepared m x y record (runPrepared + deallocate) that is a Profunctor/Arrow, so executePrepared prepares once and runs a whole Traversable of parameter tuples.
Transactions and savepoints
Transaction control is a set of combinators over MonadPQ. transactionally masks async exceptions, begins, runs the block, and commits — rolling back and re-raising on exception (Session/Transaction/Unsafe.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session/Transaction/Unsafe.hs
transactionally mode tx = mask $ \restore -> do
manipulate_ $ begin mode
result <- restore tx `onException` manipulate_ rollback
manipulate_ commit
return resultA TransactionMode bundles an IsolationLevel (Serializable/RepeatableRead/ReadCommitted/…), an AccessMode (ReadWrite/ReadOnly), and a DeferrableMode, with presets defaultMode, retryMode (serializable), and longRunningMode. Nested transactions get real savepoints: withSavepoint issues a SAVEPOINT, runs the inner block, ROLLBACK TOs it on a Left, and RELEASEs it — so an inner block can roll back without aborting the outer transaction (Session/Transaction/Unsafe.hs), in contrast to Slick, whose nested transactionally adds no savepoints. transactionallyRetry implements serialization-failure retry: it trys the block and, on a SerializationFailure or DeadlockDetected, rolls back and loops; any other exception rolls back and rethrows (Session/Transaction/Unsafe.hs). ephemerally always rolls back, for tests.
A safety refinement sits above this: the Squeal.PostgreSQL.Session.Transaction module (the non-Unsafe one) exposes a Transaction db x type "that permit[s] only database operations, pure functions, and synchronous exception handling forbidding arbitrary IO operations", so a transactional block cannot accidentally launch a missile mid-transaction (Session/Transaction.hs). The .Unsafe variants re-admit arbitrary IO when you genuinely need it.
Errors are exceptions, not a typed channel
This is where Squeal's guarantees stop. Unlike its schema story, its error model is conventional exceptions in IO, not a typed error value in the effect. Failures surface as a SquealException sum type thrown via MonadThrow (Session/Exception.hs):
-- squeal-postgresql/src/Squeal/PostgreSQL/Session/Exception.hs
data SquealException
= SQLException SQLState -- server-side SQLSTATE + message
| ConnectionException Text -- a libpq call returned failure
| DecodingException Text Text -- a DecodeRow failed
| ColumnsException Text LibPQ.Column
| RowsException Text LibPQ.Row LibPQ.RowConvenience pattern synonyms name the common SQLSTATEs — UniqueViolation (23505), CheckViolation (23514), SerializationFailure (40001), DeadlockDetected (40P01) — and catchSqueal, handleSqueal, trySqueal, throwSqueal are the MonadCatch wrappers (Session/Exception.hs). This is the key contrast with the typed-error effect mappers this survey weights most heavily: where doobie/skunk keep errors in the effect's error type and Effect TS models an SqlError union, Squeal invests its entire static budget in the schema/query dimension and handles errors the way JDBC does — as exceptions you catch. A decoding mismatch is caught statically (the row type must match), but a runtime constraint violation or serialization failure is a thrown value, not a type. For an algebraic-effects-first design, that is the precise line Squeal draws: exhaustive static typing of what SQL you run against which schema, but not of how it can fail.
Ecosystem & maturity
Squeal is a mature, single-author-led library — Eitan Chatav / Morphism, LLC — published on Hackage and Stackage under the permissive BSD-3-Clause license (LICENSE, squeal-postgresql.cabal). It is PostgreSQL-only by design and by dependency: it builds directly on postgresql-libpq (the C-level libpq binding) and postgresql-binary (binary wire codecs), plus generics-sop/records-sop for the generic encode/decode, free-categories for the Definition Category, resource-pool for pooling, and mtl/mmorph/monad-control for the transformer machinery (squeal-postgresql.cabal). Notably it does not depend on hasql, though it borrows hasql's Statement profunctor design (above).
The repo is a small monorepo: the core squeal-postgresql package plus two extension packages — squeal-postgresql-ltree (the ltree hierarchical-label type) and squeal-postgresql-uuid-ossp (the uuid-ossp generation functions) — showing the intended extension pattern for Postgres features and types beyond the core (squeal-postgresql-ltree.cabal, repository layout). The version in the pinned tree is 0.9.2.0. Documentation is unusually deep for a library of its size: extensive haddocks with doctested examples throughout, a book-length Core Concepts Handbook on the phantom-type machinery, a scrap-your-nils.md note on the generics-sop heterogeneous lists it leans on, and a recorded conference talk. Testing runs against a real Postgres on localhost (README.md).
Strengths
- The schema is a type, checked end to end. A query naming a missing column, comparing incompatible Postgres types, aggregating without
groupBy, or treating a nullable column as non-null is a compile error — the deepest static schema guarantee in the survey. - Migrations are type-tracked and reversible. A
Definition db0 db1proves it transforms one schema into a specific other; reversibleIsoQmigrations type-checkup/downas inverses; the indexedPQmonad forbids running a session against a schema a migration has not yet produced. - Structural injection safety. Values enter only as out-of-line
params encoded to binary and sent viaLibPQ.execParams; there is no string to inject into.Unsafe*escape hatches are explicitly named. - Predictable, un-optimized SQL. No AST-rewriting compiler — combinators render to the SQL you expect, inspectable with
printSQL; the generated SQL is "completely predictable." - Composable codecs, generically derived.
EncodeParams(contravariant) andDecodeRow(monadic) compose;generics-sopderives them for record types, so most codecs are free. - Full PostgreSQL surface.
WITH/CTEs, window functions, correlated subqueries, upserts, arrays, composite/enum types, JSON, ranges, text search, real savepoints, and serialization-failure retry. - mtl-friendly.
MonadPQlifts through standard transformers;PQ DB DBdrops into an app's stack.
Weaknesses
- Steep type complexity. The seven-parameter
Expressionand five-parameterQueryproduce large, hard-to-read signatures and type errors; the handbook calls the type parameters "the most complicated part of learning to use Squeal," and the trade-off is stated as "you need verbosity to get type safety." - PostgreSQL only. No dialect abstraction; the schema kinds and codecs are Postgres-specific.
- No code generation / introspection. The schema type is authored by hand; nothing reads a live database to generate it, so the type and the deployed DDL can drift (unlike
jOOQ/sqlc/slick-codegen). - Errors are exceptions, not a typed channel. Constraint violations, serialization failures, and connection errors are thrown
SquealExceptions you mustcatch— no enumerated error type in the effect, unlikedoobie/skunk/Effect TS. - Effect model is
IO-bound.PQis a transformer overIO, not an effect value interpreted by a runtime; resource lifetime isbracket/mask, not a first-classScope/Resource. - GHC-version and extension heavy. Requires many advanced extensions (
DataKinds,TypeFamilies,GADTs,UndecidableInstances,OverloadedLabels, …) and long compile times for large schemas.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Type-level Postgres schema (SchemasType of promoted kinds) | Every table/column/nullability/constraint is checkable; a bad reference is a compile error | Verbose schema types; complex signatures; PostgreSQL-specific kinds |
Phantom-typed ByteString, no reified AST | Predictable SQL, cheap rendering, all safety in the types | No query rewriting/optimization; no dialect retargeting; SQL shape is what you wrote |
Indexed monad PQ db0 db1 (Atkey) tracking schema before/after | Migrations change the type; can't query a not-yet-created table; do-notation when schema fixed | A second sequencing vocabulary (pqThen/pqBind) for schema-changing code; extra concept to learn |
Out-of-line param + EncodeParams over libpq binary | Structural injection safety; binary transfer; prepared-statement reuse | Unsafe* splices reintroduce risk; parameters are positional (@1, @2) |
Definition Category, no codegen (code-first schema) | Schema is one authoritative Haskell type; DDL derives from it (printSQL) | Type and live DB can drift; large schemas are hand-maintained; no introspection path |
Generic codecs via generics-sop (EncodeParams/DecodeRow) | Record types map to rows/params for free; composable profunctor Statement | Ties the API to generics-sop; custom encodings need IsPG/ToPG/FromPG instances |
Errors as thrown SquealExceptions | Simple; interops with MonadCatch; pattern synonyms name common SQLSTATEs | No typed error channel (unlike doobie/Effect TS); failures are runtime values, not types |
| Real savepoints for nested transactions | Inner blocks roll back independently; serialization-failure retry built in | Transaction combinators live in an .Unsafe module (arbitrary IO) unless the safe Transaction is used |
Sources
- morphismtech/squeal — GitHub repository · Hackage · Stackage
README.md— deep-embedding pitch, schema encoding,setup/getUsers/insertUserexamples, indexedPQprogramsqueal-core-concepts-handbook.md— core types vs. phantom parameters,from-scope checking, compile-error walkthroughssqueal-postgresql/src/Squeal/PostgreSQL/Type/Schema.hs—PGType/NullType/ColumnType/TableType/SchemaType/SchemasType, schema type familiesType/PG.hs—IsPGclass +PGtype family (Haskell → Postgres type mapping)Query.hs—Querynewtype, five phantom parameters,Query_, set operations, printed SQL examples ·Expression.hs·Manipulation.hs·Query/Select.hs·Definition.hsSession.hs— thePQindexed monad,MonadPQinstance,executeParams/LibPQ.execParams,withConnectionSession/Indexed.hs—IndexedMonadTrans/ Atkey indexed monad,defineSession/Monad.hs—MonadPQclass,executeParams/manipulateParams/runQuery/prepareSession/Statement.hs—Statement=Query/Manipulation+EncodeParams+DecodeRow;PreparedSession/Encode.hs—EncodeParams(contravariant),GenericParams,IsPG/ToPG·Session/Decode.hs—DecodeRow(monadic),GenericRowSession/Migration.hs—Migration/Migratory, four flavours,MigrationsTable,mainMigrateSession/Transaction/Unsafe.hs—transactionally/withSavepoint/transactionallyRetry,TransactionMode·Session/Transaction.hs— safeTransactiontypeSession/Exception.hs—SquealException,SQLSTATEpattern synonyms,catchSqueal·Session/Pool.hs—resource-poolpoolingRELEASE NOTES.md—hasqlStatement-profunctor influence,Preparedaddition ·squeal-postgresql.cabal— deps, license, modules ·LICENSE— BSD-3-Clause- Concepts: abstraction ladder · query construction models / phantom types · statements & injection · type mapping & decoding · schema & migrations · effects, transactions & errors · connections & pools