ducky

Native DuckDB driver for Gleam.

Quick Start

Build a query with sql, run it with run:

import ducky
import gleam/io
import gleam/result

pub fn main() {
  use conn <- ducky.with_connection(":memory:")

  use _ <- result.try(
    ducky.sql("CREATE TABLE ducks (name TEXT, quack_volume INT)")
    |> ducky.run(conn),
  )
  use _ <- result.try(
    ducky.sql("INSERT INTO ducks VALUES ('Duck Norris', 100)")
    |> ducky.run(conn),
  )

  use loud <- result.map(
    ducky.sql("SELECT name FROM ducks ORDER BY quack_volume DESC LIMIT 1")
    |> ducky.run(conn),
  )

  case loud.rows {
    [ducky.Row([ducky.Text(name), ..])] -> io.println(name <> " wins!")
    _ -> io.println("The pond is empty...")
  }
}

For typed rows, attach a decoder with returning:

import ducky
import gleam/dynamic/decode

pub type Duck {
  Duck(name: String, quack_volume: Int)
}

pub fn loudest(conn) {
  let duck_decoder = {
    use name <- decode.field(0, decode.string)
    use quack_volume <- decode.field(1, decode.int)
    decode.success(Duck(name:, quack_volume:))
  }

  ducky.sql("SELECT name, quack_volume FROM ducks ORDER BY quack_volume DESC")
  |> ducky.returning(duck_decoder)
  |> ducky.run(conn)
}

Types

Column-oriented result. Each inner list corresponds to one column.

pub type Columnar {
  Columnar(names: List(String), data: List(List(Value)))
}

Constructors

  • Columnar(names: List(String), data: List(List(Value)))

An opaque connection to a DuckDB database.

pub opaque type Connection

Errors that can occur during DuckDB operations.

pub type Error {
  ConnectionFailed(reason: String)
  QuerySyntaxError(message: String)
  UnsupportedParameterType(type_name: String)
  StatementFinalized
  DecodeFailed(errors: List(decode.DecodeError))
  DatabaseError(message: String)
}

Constructors

  • ConnectionFailed(reason: String)

    Connection to database failed.

  • QuerySyntaxError(message: String)

    SQL query has syntax errors.

  • UnsupportedParameterType(type_name: String)

    Unsupported parameter type in query.

  • StatementFinalized

    Statement has been finalized and cannot be used.

  • DecodeFailed(errors: List(decode.DecodeError))

    Row decoder returned errors.

  • DatabaseError(message: String)

    Generic error from DuckDB.

An opaque query builder. The phantom a tracks the decoded row type.

Construct with sql, configure with parameter/returning, terminate with run or as_columns.

pub opaque type Query(a)

Typed query result. a is the decoded row type.

pub type Returned(a) {
  Returned(count: Int, rows: List(a))
}

Constructors

  • Returned(count: Int, rows: List(a))

A single row from a query result.

pub type Row {
  Row(values: List(Value))
}

Constructors

  • Row(values: List(Value))

An opaque prepared statement for repeated execution.

Prepared statements allow you to compile a SQL query once and execute it multiple times with different parameters, avoiding repeated parsing overhead.

pub opaque type Statement

A value from a DuckDB result set.

pub type Value {
  Null
  Boolean(Bool)
  TinyInt(Int)
  SmallInt(Int)
  Integer(Int)
  BigInt(Int)
  Float(Float)
  Double(Float)
  Decimal(String)
  Text(String)
  Blob(BitArray)
  Timestamp(Int)
  Date(Int)
  Time(Int)
  Interval(months: Int, days: Int, nanos: Int)
  List(List(Value))
  Array(List(Value))
  Map(dict.Dict(String, Value))
  Struct(dict.Dict(String, Value))
  Union(tag: String, value: Value)
}

Constructors

  • Null
  • Boolean(Bool)
  • TinyInt(Int)
  • SmallInt(Int)
  • Integer(Int)
  • BigInt(Int)
  • Float(Float)
  • Double(Float)
  • Decimal(String)
  • Text(String)
  • Blob(BitArray)
  • Timestamp(Int)
  • Date(Int)
  • Time(Int)
  • Interval(months: Int, days: Int, nanos: Int)
  • List(List(Value))
  • Array(List(Value))
  • Map(dict.Dict(String, Value))
  • Struct(dict.Dict(String, Value))
  • Union(tag: String, value: Value)

Values

pub fn append(
  conn: Connection,
  table: String,
  rows: List(List(Value)),
) -> Result(Int, Error)

Bypasses SQL parsing. Atomic: all rows succeed or none commit.

Examples

let assert Ok(count) = append(conn, "users", [
  [int(1), text("Alice")],
  [int(2), text("Bob")],
])
// count == 2
pub fn as_columns(
  query: Query(Row),
  conn: Connection,
) -> Result(Columnar, Error)

Returns columns instead of rows. DuckDB’s columnar strength exposed.

Examples

ducky.sql("SELECT name, age FROM users")
|> ducky.as_columns(conn)
// => Ok(Columnar(names: ["name", "age"], data: [[Text("Alice")], [Integer(30)]]))
pub fn blob(value: BitArray) -> Value

Creates a blob parameter value.

pub fn bool(value: Bool) -> Value

Creates a boolean parameter value.

pub fn close(conn: Connection) -> Result(Nil, Error)

Closes a database connection.

Examples

let assert Ok(conn) = connect(":memory:")
let assert Ok(_) = close(conn)
pub fn column(
  columnar: Columnar,
  name: String,
) -> option.Option(List(Value))

Looks up a column by name. Returns None if not found.

Examples

ducky.column(cols, "name")
// => Some([Text("Alice"), Text("Bob")])
pub fn connect(path: String) -> Result(Connection, Error)

Opens a connection to a DuckDB database.

Must call close() when done. Use with_connection() instead for automatic cleanup.

Examples

connect(":memory:")
// => Ok(Connection(...))

connect("data.duckdb")
// => Ok(Connection(...))
pub fn date(days: Int) -> Value

Creates a date parameter value (days since Unix epoch).

pub fn date_decoder() -> decode.Decoder(Int)

Raw days since epoch.

pub fn decimal(value: String) -> Value

Creates a decimal parameter value from a string representation.

pub fn decimal_decoder() -> decode.Decoder(String)

Lossless string. Avoids floating-point representation issues.

pub fn execute(
  stmt: Statement,
  params: List(Value),
) -> Result(Returned(Row), Error)

Runs a prepared statement. For hot loops with repeated bindings.

Examples

let assert Ok(stmt) = prepare(conn, "SELECT * FROM users WHERE age > ?")
let assert Ok(result) = execute(stmt, [int(18)])
// result.rows contains matching users
pub fn field(value: Value, name: String) -> option.Option(Value)

Get a field value from a struct by field name.

Returns None if the value is not a Struct or the field does not exist.

Examples

let person = Struct(dict.from_list([#("name", Text("Alice")), #("age", Integer(30))]))
field(person, "name")
// => Some(Text("Alice"))

field(person, "unknown")
// => None
pub fn finalize(stmt: Statement) -> Result(Nil, Error)

Finalizes a prepared statement, releasing its resources.

After finalization, the statement cannot be used again. For automatic cleanup, prefer with_statement.

Examples

let assert Ok(stmt) = prepare(conn, "SELECT 1")
// ... use the statement ...
let assert Ok(_) = finalize(stmt)
pub fn float(value: Float) -> Value

Creates a float parameter value.

pub fn get(row: Row, index: Int) -> option.Option(Value)

Get a value from a row by column index.

Examples

let row = Row([Integer(1), Text("Alice")])
get(row, 0)
// => Some(Integer(1))

get(row, 5)
// => None
pub fn int(value: Int) -> Value

Creates an integer parameter value.

pub fn interval(
  months months: Int,
  days days: Int,
  nanos nanos: Int,
) -> Value

Creates an interval parameter value.

pub fn interval_decoder() -> decode.Decoder(#(Int, Int, Int))

Returns #(months, days, nanos). NIF sends {interval, m, d, n}.

pub fn null() -> Value

Creates a null parameter value.

pub fn nullable(
  inner: fn(a) -> Value,
  value: option.Option(a),
) -> Value

Creates a nullable parameter value.

Examples

nullable(int, Some(42))
// => Integer(42)

nullable(int, None)
// => Null
pub fn parameter(query: Query(a), value: Value) -> Query(a)

Binds one parameter to the next ? placeholder.

Examples

ducky.sql("SELECT * FROM users WHERE id = ?")
|> ducky.parameter(ducky.int(42))
|> ducky.run(conn)
pub fn parameters(
  query: Query(a),
  values: List(Value),
) -> Query(a)

Binds multiple parameters at once, in order.

Examples

ducky.sql("SELECT * FROM users WHERE id = ? AND age > ?")
|> ducky.parameters([ducky.int(42), ducky.int(18)])
|> ducky.run(conn)
pub fn path(conn: Connection) -> String

Useful for logging which database a connection points to.

pub fn prepare(
  conn: Connection,
  sql: String,
) -> Result(Statement, Error)

Prepares a SQL statement for repeated execution.

Validates the SQL syntax immediately and returns a statement handle. Use execute to run the statement with parameters.

Examples

let assert Ok(stmt) = prepare(conn, "INSERT INTO users (name, age) VALUES (?, ?)")
let assert Ok(_) = execute(stmt, [text("Alice"), int(30)])
let assert Ok(_) = execute(stmt, [text("Bob"), int(25)])
let assert Ok(_) = finalize(stmt)

Performance

DuckDB caches parsed query plans internally, so repeated executions with different parameters benefit from the cached plan. This can provide speedups for bulk operations.

pub fn returning(
  query: Query(Row),
  decoder: decode.Decoder(b),
) -> Query(b)

Sets a decoder for typed rows. Constrained to Query(Row). Can only be called once; the compiler enforces this.

Examples

let decoder = {
  use name <- decode.field(0, decode.string)
  use age <- decode.field(1, decode.int)
  decode.success(#(name, age))
}

ducky.sql("SELECT name, age FROM users")
|> ducky.returning(decoder)
|> ducky.run(conn)
// => Ok(Returned(count: 1, rows: [#("Alice", 30)]))
pub fn run(
  query: Query(a),
  conn: Connection,
) -> Result(Returned(a), Error)

Executes the query. Use sql for one-shot queries; prepare/execute for hot loops.

Examples

ducky.sql("SELECT 1 AS x")
|> ducky.run(conn)
// => Ok(Returned(count: 1, rows: [Row([Integer(1)])]))
pub fn sql(statement: String) -> Query(Row)

Builds a query value. No I/O until run or as_columns.

Examples

ducky.sql("SELECT * FROM users")
|> ducky.run(conn)
pub fn text(value: String) -> Value

Creates a text parameter value.

pub fn time(micros: Int) -> Value

Creates a time parameter value (microseconds since midnight).

pub fn time_decoder() -> decode.Decoder(Int)

Raw microseconds since midnight.

pub fn timestamp(micros: Int) -> Value

Creates a timestamp parameter value (microseconds since Unix epoch).

pub fn timestamp_decoder() -> decode.Decoder(Int)

Raw microseconds since epoch. NIF returns temporals as {type_tag, value} tuples.

pub fn transaction(
  conn: Connection,
  callback: fn(Connection) -> Result(a, Error),
) -> Result(a, Error)

Wraps a callback in BEGIN/COMMIT. Rolls back on error.

Examples

transaction(conn, fn(conn) {
  use _ <- result.try(ducky.sql("UPDATE accounts ...") |> ducky.run(conn))
  ducky.sql("SELECT * FROM accounts") |> ducky.run(conn)
})
pub fn with_connection(
  db_path: String,
  callback: fn(Connection) -> Result(a, Error),
) -> Result(a, Error)

Opens a connection, runs the callback, then closes. Preferred over connect/close.

Examples

use conn <- with_connection(":memory:")
ducky.sql("SELECT 42") |> ducky.run(conn)
pub fn with_statement(
  conn: Connection,
  sql: String,
  callback: fn(Statement) -> Result(a, Error),
) -> Result(a, Error)

Executes operations with a prepared statement, ensuring cleanup.

The statement is automatically finalized when the callback returns, regardless of success or failure.

Examples

use stmt <- with_statement(conn, "INSERT INTO users (name) VALUES (?)")
list.try_each(names, fn(name) {
  execute(stmt, [text(name)])
  |> result.map(fn(_) { Nil })
})
Search Document