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:

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:


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:

  1. A unified DATABASE_URL (Local development style)
  2. 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 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:

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


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


Key Takeaways

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!

Hey, this site is part of ring.muhokama.fun!