Haskell FFI bindings for DuckDB.
duckdb-simple
duckdb-simple provides a high-level Haskell interface to DuckDB inspired by the ergonomics of sqlite-simple. It builds on the low-level bindings exposed by duckdb-ffi and provides a focused API for opening connections, running queries, binding parameters, and decoding typed results—including the full set of DuckDB scalar types (signed/unsigned integers, decimals, hugeints, intervals, precise and timezone-aware temporals, blobs, enums, bit strings, and bignums).
Getting Started
{-# LANGUAGE OverloadedStrings #-}
import Database.DuckDB.Simple
import Database.DuckDB.Simple.Types (Only (..))
main :: IO ()
main =
withConnection ":memory:" \conn -> do
_ <- execute_ conn "CREATE TABLE items (id INTEGER, name TEXT)"
_ <- execute conn "INSERT INTO items VALUES (?, ?)" (1 :: Int, "banana" :: String)
rows <- query_ conn "SELECT id, name FROM items ORDER BY id"
mapM_ print (rows :: [(Int, String)])
Key Modules
Database.DuckDB.Simple– connections, prepared statements, execution, queries, metadata, and error handling.Database.DuckDB.Simple.ToField/ToRow– typeclasses and helpers for preparing positional or named parameters.Database.DuckDB.Simple.FromField/FromRow– typeclasses for decoding query results, with generic deriving support for product types.Database.DuckDB.Simple.Generic– automatic encoding/decoding of Haskell ADTs as DuckDB STRUCTs and UNIONs via GHC generics and theViaDuckDBderiving-via helper.Database.DuckDB.Simple.LogicalRep– structured value types (StructValue,UnionValue) for working with DuckDB's composite types.Database.DuckDB.Simple.Types– shared types (Query,Null,Only,(:.),SQLError).Database.DuckDB.Simple.Function– register scalar Haskell functions that can be invoked directly from SQL.
Querying Data
import Database.DuckDB.Simple
import Database.DuckDB.Simple.Types (Only (..))
fetchNames :: Connection -> IO [Maybe String]
fetchNames conn = do
_ <- execute_ conn "CREATE TABLE names (value TEXT)"
_ <- executeMany conn "INSERT INTO names VALUES (?)"
[Only (Just "Alice"), Only (Nothing :: Maybe String)]
fmap fromOnly <$> query_ conn "SELECT value FROM names ORDER BY value IS NULL, value"
The execution helpers return the number of affected rows (Int) so callers can assert on data changes when needed.
Named Parameters
duckdb-simple supports both positional (?) and named parameters. Named parameters are bound with the (:=) helper exported from Database.DuckDB.Simple.ToField.
import Database.DuckDB.Simple
import Database.DuckDB.Simple.ToField (NamedParam ((:=)))
insertNamed :: Connection -> IO Int
insertNamed conn =
executeNamed conn
"INSERT INTO events VALUES ($kind, $payload)"
["$kind" := ("metric" :: String), "$payload" := ("ok" :: String)]
DuckDB does not allow mixing positional and named placeholders within the same SQL statement; the library preserves DuckDB’s error message in that situation. Savepoints are currently rejected by DuckDB, so withSavepoint raises an SQLError describing the limitation.
If the number of supplied parameters does not match the statement’s declared placeholders—or if you attempt to bind named arguments to a positional-only statement—duckdb-simple raises a FormatError before executing the query.
Decoding rows
FromRow is powered by a RowParser, which means instances can be written in a monadic/Applicative style and even derived generically for product types:
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
import Database.DuckDB.Simple
import GHC.Generics (Generic)
data Person = Person
{ personId :: Int
, personName :: Text
}
deriving stock (Show, Generic)
deriving anyclass (FromRow)
fetchPeople :: Connection -> IO [Person]
fetchPeople conn = query_ conn "SELECT id, name FROM person ORDER BY id"
Helper combinators such as field, fieldWith, and numFieldsRemaining are available when a custom instance needs fine-grained control.
Generic Encoding with ViaDuckDB
The Database.DuckDB.Simple.Generic module provides automatic encoding and decoding of Haskell algebraic data types as DuckDB STRUCTs and UNIONs via GHC generics.
Product Types as STRUCTs
Product types (records) are automatically encoded as DuckDB STRUCT values:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
import Data.Int (Int64)
import Data.Text (Text)
import Database.DuckDB.Simple
import Database.DuckDB.Simple.Generic (ViaDuckDB (..))
import GHC.Generics (Generic)
data User = User
{ userId :: Int64
, userName :: Text
}
deriving stock (Eq, Show, Generic)
deriving (DuckDBColumnType, ToField, FromField) via (ViaDuckDB User)
-- Round-trip through the database
storeAndFetchUser :: Connection -> User -> IO [User]
storeAndFetchUser conn user = do
_ <- execute_ conn "CREATE TABLE users (data STRUCT(userId BIGINT, userName TEXT))"
_ <- execute conn "INSERT INTO users VALUES (?)" (Only user)
fmap fromOnly <$> query_ conn "SELECT data FROM users"
Sum Types as UNIONs
Sum types are encoded as DuckDB UNION values, with each constructor becoming a union member:
data Shape
= Circle Double
| Rectangle Double Double
| Point
deriving stock (Eq, Show, Generic)
deriving (DuckDBColumnType, ToField, FromField) via (ViaDuckDB Shape)
-- Store and retrieve shape data
storeShape :: Connection -> Shape -> IO [Shape]
storeShape conn shape = do
_ <- execute_ conn
"CREATE TABLE shapes (s UNION(Circle STRUCT(field1 DOUBLE), \
\Rectangle STRUCT(field1 DOUBLE, field2 DOUBLE), Point STRUCT()))"
_ <- execute conn "INSERT INTO shapes VALUES (?)" (Only shape)
fmap fromOnly <$> query_ conn "SELECT s FROM shapes"
Nullary constructors (like Point) are encoded with a null payload. Non-record constructors use positional field names (field1, field2, etc.).
Arrays and Lists
DuckDB arrays (fixed-length) and lists (variable-length) are also supported:
import Data.Array (Array, listArray)
storeArray :: Connection -> IO [Array Int Int]
storeArray conn = do
_ <- execute_ conn "CREATE TABLE arrays (vals INTEGER[3])"
let arr = listArray (0, 2) [1, 2, 3]
_ <- execute conn "INSERT INTO arrays VALUES (?)" (Only arr)
fmap fromOnly <$> query_ conn "SELECT vals FROM arrays"
storeList :: Connection -> IO [[Int]]
storeList conn = do
_ <- execute_ conn "CREATE TABLE lists (vals INTEGER[])"
_ <- execute conn "INSERT INTO lists VALUES (?)" (Only [1, 2, 3])
fmap fromOnly <$> query_ conn "SELECT vals FROM lists"
Manual STRUCT and UNION Handling
For more control, you can work directly with StructValue and UnionValue from Database.DuckDB.Simple.LogicalRep:
import Database.DuckDB.Simple.LogicalRep (StructValue (..), UnionValue (..))
import Database.DuckDB.Simple.FromField (FieldValue (..))
manualStruct :: Connection -> IO [(StructValue FieldValue, UnionValue FieldValue)]
manualStruct conn = do
_ <- execute_ conn
"CREATE TABLE composite (s STRUCT(a INT, b INT), \
\u UNION(x INT, y VARCHAR))"
[(s, u)] <- query_ conn
"SELECT {'a': 1, 'b': 2}, \
\CAST(union_value(x := 42) AS UNION(x INT, y VARCHAR))"
_ <- execute conn "INSERT INTO composite VALUES (?, ?)" (s, u)
query_ conn "SELECT s, u FROM composite"
Resource Management
withConnectionandwithStatementwrap the open/close lifecycle and guard against exceptions; use them whenever possible to avoid leaking C handles.- All intermediate DuckDB objects (results, prepared statements, values) are released immediately after use. Long queries still materialise their result sets when using the eager helpers; reach for
fold/fold_/foldNamed(or the lower-levelnextRow) to stream results in constant space. execute/queryvariants reset statement bindings each run so prepared statements can be reused safely.
Metadata helpers
columnCountandcolumnNameexpose prepared-statement metadata so you can inspect result shapes before executing a query.rowsChangedtracks the number of rows affected by the most recent mutation on a connection. DuckDB does not offer alastInsertRowId; prefer SQLRETURNINGclauses when you need generated identifiers.
Streaming Results
fold, fold_, and foldNamed expose DuckDB’s chunked result API, letting you aggregate or stream rows without materialising the entire result set:
import Database.DuckDB.Simple.Types (Only (..))
sumValues :: Connection -> IO Int
sumValues conn =
fold_ conn "SELECT n FROM stream_fold ORDER BY n" 0 $ \acc (Only n) ->
pure (acc + n)
For manual cursor-style iteration, use nextRow/nextRowWith on an open Statement to pull rows one at a time and decide when to stop.
Feature Coverage
- Connections, prepared statements, positional/named parameter binding.
- High-level execution (
execute*) and eager queries (query*,queryNamed). - Streaming helpers (
fold,foldNamed,fold_,nextRow) for constant-space result processing. - Comprehensive scalar type support: signed/unsigned integers, HUGEINT/UHUGEINT, decimals (with width/scale), intervals, precise and timezone-aware temporals, enums, bit strings, blobs, bignums, and UUIDs.
- Composite types: STRUCTs, UNIONs, LISTs, fixed-length ARRAYs, and MAPs with full encoding/decoding support.
- Generic encoding/decoding: automatic STRUCT/UNION mapping for Haskell ADTs via GHC generics and the
ViaDuckDBderiving-via helper. - Row decoding via
FromField/FromRow, with generic deriving for product types. - User-defined scalar functions backed by Haskell functions (including IO and nullable arguments).
- Transaction helpers (
withTransaction) and metadata accessors (columnCount,columnName,rowsChanged).
User-Defined Functions
Scalar Haskell functions can be registered with DuckDB connections and used in SQL expressions. Argument and result types reuse the existing FromField and FunctionResult machinery, so Maybe values and IO actions work out of the box.
import Data.Int (Int64)
import Database.DuckDB.Simple
import Database.DuckDB.Simple.Function (createFunction, deleteFunction)
import Database.DuckDB.Simple.Types (Only (..))
registerAndUse :: Connection -> IO [Only Int64]
registerAndUse conn = do
createFunction conn "hs_times_two" (\(x :: Int64) -> x * 2)
result <- query_ conn "SELECT hs_times_two(21)" :: IO [Only Int64]
deleteFunction conn "hs_times_two"
pure result
Exceptions raised while the function executes are propagated back to DuckDB as SQLError values, and deleteFunction issues a DROP FUNCTION IF EXISTS statement to remove the registration. DuckDB registers C API scalar functions as internal entries; attempting to drop them this way will yield an error, which the library surfaces as an SQLError.
Tests
The test suite is built with tasty and covers connection management, statement lifecycle, parameter binding, and query execution.
cabal test duckdb-simple-test --test-show-details=direct