# Hasql-check: check your hasql-th SQL against a real PostgreSQL schema

**URL:** <https://discourse.haskell.org/t/hasql-check-check-your-hasql-th-sql-against-a-real-postgresql-schema/14912>\
**Category:** Announcements\
**Created:** [October 29, 2026, 3:25pm UTC](https://discourse.haskell.org/t/hasql-check-check-your-hasql-th-sql-against-a-real-postgresql-schema/14912 "2026-10-29T15:25:49Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![lorenzo](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/lorenzo/32/1294_2.png) [@lorenzo](https://discourse.haskell.org/u/lorenzo)\
**Post date:** [October 29, 2026, 3:25pm UTC](https://discourse.haskell.org/t/hasql-check-check-your-hasql-th-sql-against-a-real-postgresql-schema/14912/1 "2026-10-29T15:25:50Z")

</div>

We love and make extensive use of `hasql-th`, as a big chunk of our application is written in pure SQL. I personally think that we have been spoiled with this piece of technology that other languages could only dream about and only recently started enjoying. Today we want to contribute back a piece of tooling that has saved our backs a few times already.

[hasql-th](https://hackage.haskell.org/package/hasql-th) checks that the SQL in a quasiquote _parses_. It cannot check that it means anything against your schema: the misspelled column, the one a migration dropped, the `int8` you annotated `int4`. Those are found at runtime, by the first request that happens to hit that statement.

`hasql-check` asks Postgres instead. It finds the hasql-th quotes in your source and `PREPARE`s each statement against a live database:

```haskell
$ hasql-check src/Shop/Customers.hs
Error in src/Shop/Customers.hs:12:5
ERROR: column "emial" does not exist
LINE 1: SELECT emial :: text FROM customer WHERE id = $1 :: int8
               ^
HINT: Perhaps you meant to reference the column "customer.email".

```

@pranaysashank wrote the first version of the checker at work four years ago. It has been guarding our statements ever since, and has caught a good number of bugs that would otherwise have reached production.

### Three ways to use it

**1. As a command, in CI.** Point it at a database with your migrations applied and give it every module; it exits 1 when a statement does not prepare. Two further checks are opt-in:

- `--nullability` — a column annotated non-null that can in fact be null, because the column is nullable or because it comes from the nullable side of an outer join. (hasql decodes `nickname :: text` as `Text`; a `NULL` there fails at runtime, for that one row.)
- `--types` — an annotation that narrows a value: `total_cents :: int4` on a `bigint` column, or an `int4` parameter assigned to a `smallint` column. Both work until a value does not fit.

An existing codebase will have findings, so you can record them with `--write-baseline` and fail CI only on new ones. Entries are keyed by a hash of the statement, so moving or reformatting a quote keeps its entry and changing its SQL drops it. A single finding can be allowed in place with a comment above the quote: `-- hasql-check: allow nullable(nickname)`.

**2. As a drop-in for `Hasql.TH`, while GHC compiles.** `Hasql.Check.TH` re-exports hasql-th’s quasiquoters and checks each statement as it expands:

```haskell
import Hasql.Check.TH -- instead of: import Hasql.TH

```

With `HASQL_CHECK_DATABASE_URL` set, a bad statement is a GHC error at the quote, in the build output and in your editor. Without the variable nothing is checked, so CI and release builds behave exactly as with `Hasql.TH`, and if the database cannot be reached the build warns once and carries on. Each check is about a millisecond, over one connection shared by every quote.

**3. Without annotations at all** — the new part in 0.5. `Hasql.Check.Inferred.TH` has quoters that take plain SQL and get the parameter and result types from the Haskell type the statement is used at:

```haskell
import Hasql.Check.Inferred.TH

profile :: Statement Int64 (Maybe (Text, Maybe Text))
profile = [maybeStatement| select email, nickname from customer where id = $1 |]

```

The quoter annotates the parameters and casts the result columns to the types they are read as, in a projection around your statement, which is left as written:

```sql
WITH hasql_check_result (c1, c2) AS (SELECT email, nickname FROM customer WHERE id = $1 :: int8)
SELECT c1 :: text, c2 :: text FROM hasql_check_result

```

So a `varchar` column can be read as `Text` and an `int4` one as `Int64`, which hasql’s decoders otherwise refuse at runtime. The type need not be on the binding, as GHC infers it from how the rows are used, like any other value.

Your own types work through one class: a `newtype` deriving `Value` is sent as what it wraps, `deriving (Value) via Domain "quantity" Int16` casts to a domain so Postgres checks its constraints, and `deriving (Value) via PgEnum "payment_state" PaymentState` is a Postgres enum whose labels are the constructor names — which the checker compares with the enum’s, per statement: a label the Haskell type cannot read fails where the statement reads it, one the database does not have fails where it is sent.

Those types are only known after GHC has type-checked the module, which is after the quoter ran. So a companion package, `hasql-check-ghc`, gets them from GHC. `Hasql.Check.Plugin` checks the statements while the module compiles:

```haskell
build-depends: hasql-check-ghc
ghc-options: -fplugin=Hasql.Check.Plugin

```

```haskell
src/Shop/Newtypes.hs:35:34: error:
    hasql-check: the statement does not fit HASQL_CHECK_DATABASE_URL's schema:
      column 1 (nickname) is text, but is read as Shop.Newtypes.Nickname (bigint), which Postgres converts it to only when told to, and can fail to at runtime

```

And `hasql-check-hie` reads the same types out of GHC’s `.hie` files for the CI run. It needs no database, so it can run inside a sandboxed build such as a Nix derivation, with only the checking step outside it:

```haskell
$ cabal build --ghc-options='-fwrite-ide-info -hiedir=.hie'
$ hasql-check-hie $(find .hie -name '*.hie') > statements.jsonl
$ hasql-check --inferred statements.jsonl --nullability --types

```

### Before and after

The types used to be written twice, in SQL and in Haskell, with nothing checking either against the schema:

```haskell
import Hasql.TH

orderTotal :: Statement Int64 (Maybe Int32)
orderTotal =
    [maybeStatement|
        select total_cents :: int4
        from orders
        where id = $1 :: int8
    |]

```

Now the SQL says what it does, the Haskell type says what comes back, and `total_cents` being a `bigint` is a warning rather than an overflow in production:

```haskell
import Hasql.Check.Inferred.TH

orderTotal :: Statement Int64 (Maybe Int32)
orderTotal = [maybeStatement| select total_cents from orders where id = $1 |]

```

```haskell
Warning in src/Shop/Orders.hs:15:14
  column 1 (total_cents) is read as integer, but is a bigint: it may overflow

```

### Limitations

- The plugin and `hasql-check-hie` use GHC’s own library, so they follow GHC releases: this one supports **9.12 and 9.14**. The `hasql-check` library and command do not depend on the GHC API, and are tested on 9.10 too.
- The quoters without annotations do not take composite types yet, nor arrays of more than one dimension.
- The nullability and narrowing checks skip statements they cannot take apart (`UNION`s, `SELECT *`), and views and expressions such as `count(*)`, about which Postgres does not say whether they can be null.

### Links

- Hackage: [hasql-check: Check hasql-th statements against a live PostgreSQL schema](https://hackage.haskell.org/package/hasql-check) and  
[hasql-check-ghc: Check inferred hasql statements with what GHC knows of their types](https://hackage.haskell.org/package/hasql-check-ghc)
- Source, and a README with every option:  
[GitHub - kronor-io/hasql-check: Check hasql-th statements against a live PostgreSQL schema · GitHub](https://github.com/kronor-io/hasql-check)

Bug reports and questions welcome on the issue tracker.

---

<div class="post-metadata">

**Author:** ![Axman6](https://avatars.discourse-cdn.com/v4/letter/a/ce73a5/32.png) [@Axman6](https://discourse.haskell.org/u/Axman6)\
**Post date:** [October 31, 2026, 1:52am UTC](https://discourse.haskell.org/t/hasql-check-check-your-hasql-th-sql-against-a-real-postgresql-schema/14912/2 "2026-10-31T01:52:55Z")

</div>

Very cool, this feels like a lighter weight version of Nikita’s pGenie?
