Type-safe PostgreSQL bindings for Lean 4 via libpq FFI.
Compile-time column verification · Permission-tracked queries · Structural injection prevention
lean-pq makes invalid database operations a compile-time error. Column references carry proofs, permissions are tracked in the type system, and user values are structurally separated from SQL — no runtime checks, no overhead.
Column references in queries carry a proof that the column exists in the schema. Reference a column that doesn't exist and the code won't compile.
def users : TableSchema :=
{ name := "users"
columns := [
{ name := "id", type := .serial, nullable := false },
{ name := "name", type := .text, nullable := false },
{ name := "email", type := .text, nullable := false },
{ name := "age", type := .integer, nullable := true }
] }
-- Compiles: "name" exists in the schema
def valid : Expr users.columns := .col "name" (by decide)
-- Won't compile: "phone" is not a column
-- def bad : Expr users.columns := .col "phone" (by decide)The by decide tactic evaluates the membership check at compile time. No reflection, no runtime cost — the proof is erased after elaboration.
Try it live — uncomment the bad definition to see the compile error.
PqM perm α tracks the permission level in the type. SELECT operations live in readOnly, INSERT/UPDATE/DELETE in dataAltering, DDL in admin. Lower permissions lift into higher contexts automatically, but the reverse is a type error.
-- Compiles: SELECT lifts into dataAltering context
def migrate : PqM .dataAltering Unit := do
let rows ← PqM.query (pq! select schema) -- readOnly, lifts up
let _ ← PqM.execModify "INSERT INTO ..." -- dataAltering, matches
pure ()
-- Won't compile: can't use INSERT in a readOnly context
-- def oops : PqM .readOnly Unit := do
-- let _ ← PqM.execModify "INSERT INTO ..."Permissions are a compile-time artifact — erased at runtime with zero overhead. The PqM monad is a reader over the connection handle, and MonadLift instances handle the subtyping.
Try it live — uncomment unsafeOp to see the permission violation.
User values never appear in the SQL string. The query AST renders all literals as $1, $2, ... parameters, making SQL injection structurally impossible — not by escaping, but by construction.
-- Malicious input is safely contained in the parameter array
let userInput := "Robert'; DROP TABLE users;--"
let q := pq! select users | name = userInput
let (sql, params) := q.render
-- sql: "SELECT * FROM users WHERE (name = $1)"
-- params: #["Robert'; DROP TABLE users;--"]Try it live — run it to see the injection attempt safely contained.
A SQL-like DSL that expands to the typed Query AST at compile time. Column names are verified against the schema, values become parameters, and the required permission level is inferred.
open LeanPq.Syntax
let products : TableSchema := { name := "products", columns := [
{ name := "id", type := .serial, nullable := false },
{ name := "name", type := .text, nullable := false },
{ name := "price", type := .numeric (some 10) (some 2), nullable := false },
{ name := "in_stock", type := .boolean, nullable := false }
] }
-- SELECT with WHERE, ORDER BY, LIMIT
pq! select products [name, price] | price > "5.00" orderby price desc limit 10
-- → "SELECT name, price FROM products WHERE (price > $1) ORDER BY price DESC LIMIT 10"
-- params: #["5.00"]
-- INSERT
pq! insert products [name, price, in_stock] ["Widget", "9.99", true]
-- → "INSERT INTO products (name, price, in_stock) VALUES ($1, $2, TRUE)"
-- params: #["Widget", "9.99"]
-- UPDATE with WHERE
pq! update products [price := "12.99"] | name = "Widget"
-- → "UPDATE products SET price = $1 WHERE (name = $2)"
-- DELETE, CREATE, DROP
pq! delete products | name = "Widget"
pq! create products
pq! drop_if_exists productsExpressions support =, !=, >, <, >=, <=, &&, ||, !, like, ilike, is_null, is_not_null.
import LeanPq
open LeanPq
open LeanPq.Syntax
def main : IO Unit :=
PqM.withConnectionIO (perm := .admin) "host=localhost dbname=mydb" do
let schema : TableSchema :=
{ name := "users"
columns := [
{ name := "id", type := .serial, nullable := false },
{ name := "name", type := .text, nullable := false },
{ name := "email", type := .text, nullable := false }
] }
let _ ← PqM.execQuery (pq! drop_if_exists schema)
let _ ← PqM.execQuery (pq! create schema)
let _ ← PqM.execQuery (pq! insert schema [name, email] ["Alice", "alice@example.com"])
let _ ← PqM.execQuery (pq! insert schema [name, email] ["Bob", "bob@example.com"])
let rows ← PqM.query (pq! select schema [name] | name = "Alice")
PqM.liftIO (IO.println s!"Found: {rows}") -- [["Alice"]]
let _ ← PqM.execQuery (pq! update schema [email := "new@example.com"] | name = "Bob")
let _ ← PqM.execQuery (pq! delete schema | name = "Bob")
let _ ← PqM.execQuery (pq! drop_if_exists schema)Each concurrent query runs on its own connection via Lean tasks. No locks, no shared mutable state.
-- Run three queries in parallel, each on a fresh connection
let results ← PqM.concurrent conninfo [
do let r ← PqM.execSelect "SELECT count(*) FROM orders;"; PqM.fetchAll r,
do let r ← PqM.execSelect "SELECT count(*) FROM users;"; PqM.fetchAll r,
do let r ← PqM.execSelect "SELECT count(*) FROM products;"; PqM.fetchAll r
]
-- Spawn a background query and await it later
let task ← PqM.spawnOnNewConn conninfo do
let r ← PqM.execSelect "SELECT * FROM large_table;"
PqM.fetchAll r
-- ... do other work on the current connection ...
let rows ← PqM.await task
-- Run two queries concurrently, get both results
let (users, orders) ← PqM.both conninfo
(do let r ← PqM.execSelect "SELECT * FROM users;"; PqM.fetchAll r)
(do let r ← PqM.execSelect "SELECT * FROM orders;"; PqM.fetchAll r)- Lean 4 (v4.24.0+)
libpq—brew install libpq(macOS) orapt install libpq-dev(Ubuntu)pkg-config
In your lakefile.lean, add lean-pq as a dependency:
require leanpq from git "https://github.com/user/lean-pq" @ "main"lake build # build library
docker compose -f Tests/docker-compose.yml up -d # start test database
lake test # run test suiteLeanPq/
Extern.lean — @[extern] FFI declarations (Handle, PGresult, ~80 libpq bindings)
extern.c — C implementations wrapping libpq with Lean external classes
Error.lean — LeanPq.Error inductive type
DataType.lean — PostgreSQL data type model (Chapter 8)
Schema.lean — Column, TableSchema, decidable hasCol
Monad.lean — PqM permission-tracking monad
Query.lean — SQL AST with schema-indexed expressions, parameterized rendering
Syntax.lean — pq! macro DSL
Async.lean — PqTask, spawnOnNewConn, concurrent, both
Opaque Handle and PGresult types wrap C pointers with finalizers — the Lean GC calls PQfinish/PQclear automatically. Enum types (ConnStatus, ExecStatus) have constructors ordered to match PostgreSQL's C enum ordinals exactly.