Modern PostgreSQL Connection Pooling in OCaml (2025)
A hands-on guide to wiring up PostgreSQL in an OCaml web application using Caqti, Lwt, and the Dream web framework — with environment-driven configuration, connection pooling, health diagnostics, and graceful shutdown.
This article documents the journey (and the sharp edges) of getting a clean, inspectable, self-managed connection pool running for an MVP project ("Chaufr"). It’s intentionally low-level enough to teach the pieces, while staying production-conscious.
Why Roll a Light Pool Instead of Using Caqti’s Built-in Pool?
Caqti offers connect_pool
, but building a minimal layer
yourself:
- Makes lifecycle + observability explicit
- Lets you inject logging / masking / metrics early
- Teaches how Caqti’s connection abstraction works (first-class modules)
- Gives you graceful degrade paths (start server even if DB init fails)
When you outgrow this, you can swap in Caqti’s managed pool with near-zero API disruption.
Our Solution Architecture
High-Level Architecture
Environment → Config Parser → URI Builder → Pool (Queue + Mutex) → with_connection → Queries & Health
│
Dream Routes (/health, /health/detailed)
Core Components
Core modules / files:
-
lib/server/database/connection.ml
– pooling + config + health logic -
bin/main.ml
– startup flow, health endpoints, graceful shutdown -
lib/server/database/dune
– library dependencies (notablycaqti-lwt.unix
)
Implementation Guide
Dependencies (2025 Stack)
Dune library stanza (key parts):
(library
(public_name chaufr.server.database)
(name database)
(libraries
uri
lwt
containers ; optional helpers (future use)
caqti
caqti-lwt
caqti-lwt.unix ; IMPORTANT: provides the Unix connector (connect)
caqti-driver-postgresql
simple_dotenv))
Why caqti-lwt.unix
? The plain
caqti-lwt
exposes types, but the actual
connect
function lives in the platform package (caqti_lwt_unix
module). Missing it yields the classic:
Error: Unbound value Caqti_lwt.connect
Fix: depend on caqti-lwt.unix
and call
Caqti_lwt_unix.connect
.
Configuration Strategy
We support two input surfaces:
- A unified
DATABASE_URL
(Local development style) -
Individual environment variables (Azure / custom provisioning):
-
POSTGRESQL_ADDON_HOST
,POSTGRESQL_ADDON_PORT
,POSTGRESQL_ADDON_DB
,POSTGRESQL_ADDON_USER
,POSTGRESQL_ADDON_PASSWORD
,POSTGRESQL_ADDON_CONNECTION_POOL
-
Password masking avoids leaking secrets:
let mask_password config = { config with password = "***" }
Config record:
type db_config = {
host : string;
port : int;
database : string;
user : string;
password : string;
pool_size : int;
log_level : string;
}
Parsing path from DATABASE_URL
safely:
match Uri.path uri with
| "" | "/" -> "postgres"
| path -> String.sub path 1 (String.length path - 1)
Database URI Construction
let build_uri c =
Printf.sprintf "postgresql://%s:%s@%s:%d/%s"
c.user c.password c.host c.port c.database
Everything is explicit — no hidden string mutation or global builder.
Connection Pool Implementation
Pool Structure
Lightweight pool with a mutex + queue of first-class connections:
type pool = {
connections : Caqti_lwt.connection Queue.t;
mutex : Lwt_mutex.t;
max_size : int;
uri : Uri.t;
}
We store modules-as-values via Caqti’s packed
connection ((module Caqti_lwt.CONNECTION)
pattern). This
stays implementation-agnostic.
Creating one connection
let create_connection (uri: Uri.t) =
let* r = Caqti_lwt_unix.connect uri in
match r with
| Ok (module Connection as m) -> Lwt.return (Ok m)
| Error err ->
Lwt.return (Error (`Connection_error (Caqti_error.show err)))
Pool initialization
let create_pool config =
let uri = Uri.of_string (build_uri config) in
let pool = { connections = Queue.create (); mutex = Lwt_mutex.create ();
max_size = config.pool_size; uri } in
(* Warm one connection *)
let* first = create_connection uri in
match first with
| Ok conn -> Queue.add conn pool.connections; Lwt.return (Ok pool)
| Error e -> Lwt.return (Error e)
Checkout / Return
let get_connection pool =
let* () = Lwt_mutex.lock pool.mutex in
let op =
if Queue.is_empty pool.connections then create_connection pool.uri
else Lwt.return (Ok (Queue.take pool.connections))
in
Lwt_mutex.unlock pool.mutex; op
let return_connection pool conn =
let* () = Lwt_mutex.lock pool.mutex in
if Queue.length pool.connections < pool.max_size then
Queue.add conn pool.connections;
Lwt_mutex.unlock pool.mutex; Lwt.return ()
Scoped Usage
let with_connection f =
match get_pool () with
| Error e -> Lwt.return (Error e)
| Ok pool ->
let* c_res = get_connection pool in
match c_res with
| Error e -> Lwt.return (Error e)
| Ok conn ->
Lwt.finalize
(fun () -> let* r = f conn in Lwt.return (Ok r))
(fun () -> return_connection pool conn)
This ensures connections are returned even on exceptions (thanks to
Lwt.finalize
).
Health & Diagnostics
Monitoring Endpoints
Exposed via Dream
:
/health
– liveness (fast OK)-
/health/detailed
– pooled connection +SELECT 1
+SELECT version()
/diagnostic
– masked environment snapshot-
/status
– runtime metadata (timestamp, OCaml version)
Health assembly:
let ping_query = (Caqti_type.unit ->! Caqti_type.int) @@ "SELECT 1"
let version_query = (Caqti_type.unit ->! Caqti_type.string) @@ "SELECT version()"
Status string on success:
Database Health: OK
Ping: Database ping successful
Version: PostgreSQL 16.x (...)
Production Concerns
Graceful Startup & Fallback
We separate DB init from HTTP server run:
let db_init_result =
Lwt_main.run (let* r = Connection.init () in Lwt.return r)
If init fails we still boot the HTTP server (degraded mode) — useful for
ops: system returns 503 on /health/detailed
but stays
reachable.
Graceful Shutdown (SIGTERM)
Sys.set_signal Sys.sigterm
(Signal_handle (fun _ ->
Printf.printf "Received SIGTERM...";
Lwt.async (fun () -> Connection.close ())));
The pool’s close
clears queued connections and resets the
global ref.
Error Taxonomy
Custom variant keeps errors actionable:
type connection_error =
[ `Config_error of string
| `Connection_error of string
| `Pool_error of string
]
Render via:
let error_to_string = function
| `Config_error m -> "Configuration Error: " ^ m
| `Connection_error m -> "Connection Error: " ^ m
| `Pool_error m -> "Pool Error: " ^ m
This propagates uniformly through Result
+ Lwt.
Common Pitfalls (and Fixes)
Symptom | Cause | Fix |
---|---|---|
Unbound value Caqti_lwt.connect |
Missing platform connector |
Use caqti-lwt.unix +
Caqti_lwt_unix.connect
|
Unbound module Lwt in editor | Language server not seeing dune context | Run dune build , restart ocamllsp |
Blank health version | Pool not initialized |
Ensure Connection.init() runs before calling endpoint
|
Leaked password in logs | Printed raw URI | Mask & structure logs (use mask_password ) |
Getting Started
Minimal Example
(* Acquire & init once at startup *)
match Lwt_main.run (Connection.init ()) with
| Ok () -> print_endline "Pool ready"
| Error e -> prerr_endline (Connection.error_to_string e)
(* Using in a handler *)
let* status = Connection.get_health_status () in
match status with
| Ok s -> Dream.respond s
| Error e -> Dream.respond ~status:`Service_Unavailable (Connection.error_to_string e)
Why This Matters in 2025
The OCaml ecosystem keeps maturing, but approachable, production-minded examples remain scarce. This implementation shows you can:
- Keep types strong without over-abstraction
- Avoid deadlocks by disciplined mutex usage
- Defer heavier frameworks until you need them
- Offer clear operational surfaces (health & diagnostics)
It’s a stepping stone: add metrics, structured logging, OpenTelemetry spans, then swap to Caqti’s pool or another layer when throughput demands.
Next Steps
- Add
queries.ml
with prepared statements + decoders -
Introduce migrations (
migrations.ml
) with forward/rollback -
Attach tracing (OpenTelemetry) around
with_connection
- Implement backpressure (queue wait when pool exhausted)
- Add JSON health output variant for orchestrators / Kubernetes
Reference
Environment Variables
Variable | Purpose | Fallback |
---|---|---|
DATABASE_URL |
Unified connection string | Parsed first if present |
POSTGRESQL_ADDON_HOST |
Hostname | DB_HOST / default error |
POSTGRESQL_ADDON_PORT |
Port | 5432 |
POSTGRESQL_ADDON_DB |
Database name | DB_NAME |
POSTGRESQL_ADDON_USER |
Username | DB_USER |
POSTGRESQL_ADDON_PASSWORD |
Password | DB_PASSWORD |
POSTGRESQL_ADDON_CONNECTION_POOL |
Pool size | 20 |
LOG_LEVEL |
Verbosity | info |
Acknowledgements
-
Environment variable naming & layered config inspiration:
muhōkama its
.test_env
file which demonstrated a clear pattern for POSTGRESQL_ADDON_* variables and encouraged masking + explicit pool sizing. -
Thanks to the Caqti maintainers for the modular connector design
(separation of
caqti-lwt
andcaqti-lwt.unix
clarified the platform dependency). - This article is a collaborative effort; adapt and improve it for your own stacks—crediting upstream inspirations strengthens the OCaml ecosystem.
Key Takeaways
- Caqti’s packed modules make dialect / driver changes invisible to callers.
- Explicit init + fallback increases resilience during transient DB outages.
- Health layering (basic vs detailed) aligns with modern platform expectations.
- Custom pool = learning + instrumentation playground before adopting heavier infra.
If this helped you wire PostgreSQL into your OCaml service — pass it forward. The ecosystem grows when we share the gritty paths, not just the polished libraries.
Have questions about OCaml, PostgreSQL, or connection pooling? Reach out!