Dates, Times and Timestamps

VectorReader and VectorWriter read and write DuckDB's DATE, TIME, TIMETZ, TIMESTAMP and INTERVAL values as the raw integers DuckDB stores; quack_rs::datetime converts them to and from calendar fields. This page also covers DECIMAL and HUGEINT, whose conversions live in the same module.

SQL typeStorageAccessor
DATEi32 — days since 1970-01-01read_date / write_date
TIMEi64 — microseconds since midnightread_time / write_time
TIMETZpacked u64read_time_tz / write_time_tz
TIMESTAMPi64 — microseconds since the epochread_timestamp / write_timestamp
TIMESTAMPTZi64 — microseconds since the epoch, UTCread_timestamp_tz / write_timestamp_tz
TIMESTAMP_Si64 — seconds since the epochread_timestamp_s / write_timestamp_s
TIMESTAMP_MSi64 — milliseconds since the epochread_timestamp_ms / write_timestamp_ms
TIMESTAMP_NSi64 — nanoseconds since the epochread_timestamp_ns / write_timestamp_ns
INTERVAL{ months: i32, days: i32, micros: i64 }read_interval / write_interval (see INTERVAL Type)

Turning those integers into year/month/day means implementing the proleptic Gregorian calendar, and getting it to agree with DuckDB's SQL semantics exactly rather than approximately. DuckDB already exposes the conversions, and they are in the stable prefix of the C API, so quack_rs::datetime wraps them instead of reimplementing them. They need no feature flag.

Decomposing and composing

#![allow(unused)]
fn main() {
use quack_rs::vector::{VectorReader, VectorWriter};
fn demo(reader: &VectorReader, writer: &mut VectorWriter, row: usize) {
use quack_rs::datetime;

// DATE -> calendar date
let days = unsafe { reader.read_date(row) };
let date = unsafe { datetime::date_from_days(days) };
println!("{:04}-{:02}-{:02}", date.year, date.month, date.day);

// …and back. `None` means DuckDB cannot represent the date.
match unsafe { datetime::date_to_days(date) } {
    Some(days) => unsafe { writer.write_date(row, days) },
    None => unsafe { writer.set_null(row) },
}
}
}

Time, TimeTz and Timestamp work the same way:

#![allow(unused)]
fn main() {
use quack_rs::datetime;
use quack_rs::vector::{VectorReader, VectorWriter};
fn demo(reader: &VectorReader, writer: &mut VectorWriter, rows: usize) {
for row in 0..rows {
let Some(ts) = (unsafe { datetime::timestamp_from_micros(reader.read_timestamp(row)) }) else {
    // ±infinity (or the first ~4 hours of the i64 range): no calendar form.
    unsafe { writer.set_null(row) };
    continue;
};
assert!((0..1_000_000).contains(&ts.time.micros));   // ts.date and ts.time are plain structs

let micros = unsafe { datetime::timestamp_to_micros(ts) };   // Option<i64>
}
}
}

Invalid input is None, not an abort

Several of DuckDB's conversions throw a C++ exception on bad input, and the C API does not catch it — so calling them directly with, say, month 13 aborts the whole process ("Rust cannot catch foreign exceptions"). The wrappers apply DuckDB's own conditions first and return None instead:

FunctionReturns None when
date_to_daysmonth not 1–12, day not in that month (leap years included), or the date is outside 5877642-06-25 BC – 5881580-07-10; datetime::is_valid_date is the same check
timestamp_from_microsthe value is ±infinity, or below -106_751_991 * MICROS_PER_DAY (which includes i64::MIN)
timestamp_to_microsthe date is invalid, the result overflows i64, or it lands on ±infinity
time_from_microsthe value is outside 0..=MICROS_PER_DAY (00:00:00–24:00:00)
time_tz_bitsthe time is outside 0..=MICROS_PER_DAY, or the offset beyond ±15:59:59 (TIME_TZ_MAX_OFFSET_SECONDS)
time_tz_from_bitsthe bits decode to a time or offset that time_tz_bits would refuse
decimal_to_f64width > 38 or scale > width

time_from_micros and time_tz_from_bits guard an assertion rather than an exception: a release build of DuckDB decomposes an out-of-range time into out-of-range fields, and a build with assertions enabled aborts.

time_to_micros does no range check, exactly like DuckDB: an hour of 25 simply gives a TIME past midnight.

TIMETZ is a packed 64-bit value, not a plain integer — build and read it through the helpers rather than by hand:

#![allow(unused)]
fn main() {
use quack_rs::datetime;
use quack_rs::vector::{VectorReader, VectorWriter};
fn demo(reader: &VectorReader, writer: &mut VectorWriter, row: usize) {
let bits = unsafe { datetime::time_tz_bits(12 * 3_600 * 1_000_000, -5 * 3_600) }
    .expect("noon, UTC-5, is in range");
unsafe { writer.write_time_tz(row, bits) };

let decoded = unsafe { datetime::time_tz_from_bits(reader.read_time_tz(row)) }
    .expect("DuckDB wrote a valid TIMETZ");
assert_eq!(decoded.offset_seconds, -5 * 3_600);
}
}

Infinity

DuckDB reserves two values of DATE and of TIMESTAMP for infinity and -infinity. Decomposing one into a calendar date is meaningless, so check first:

#![allow(unused)]
fn main() {
use quack_rs::datetime;
use quack_rs::vector::{VectorReader, VectorWriter};
fn demo(reader: &VectorReader, writer: &mut VectorWriter, row: usize) {
let days = unsafe { reader.read_date(row) };
if unsafe { datetime::is_finite_date(days) } {
    let date = unsafe { datetime::date_from_days(days) };
    // …
}
}
}

Note the exact values, which are easy to get wrong:

ConstantValue
DATE_INFINITY_DAYSi32::MAX
DATE_NEGATIVE_INFINITY_DAYS-i32::MAX
TIMESTAMP_INFINITY_MICROSi64::MAX
TIMESTAMP_NEGATIVE_INFINITY_MICROS-i64::MAX

Negative infinity is -i32::MAX, not i32::MIN. i32::MIN is an ordinary (if absurd) finite date, and treating it as infinity would silently drop real rows.

DECIMAL

DECIMAL is stored in the narrowest integer that fits its declared width, so the width has to travel with the value:

Declared widthPhysical storage
1 – 4i16
5 – 9i32
10 – 18i64
19 – 38i128

read_decimal / write_decimal take the width and pick the right one. Get it from the column's LogicalType:

#![allow(unused)]
fn main() {
use libduckdb_sys::duckdb_vector;
use quack_rs::vector::{VectorReader, VectorWriter};
fn demo(vec: duckdb_vector, reader: &VectorReader, writer: &mut VectorWriter, row: usize) {
let logical = unsafe { quack_rs::vector::vector_get_column_type(vec) };
let width = unsafe { logical.decimal_width() };
let scale = unsafe { logical.decimal_scale() };

let unscaled = unsafe { reader.read_decimal(row, width) };
// The represented number is unscaled / 10^scale. The doubled value must
// still fit in `width` digits; write_decimal does not check.
unsafe { writer.write_decimal(row, width, unscaled * 2) };
}
}

datetime::f64_to_decimal and datetime::decimal_to_f64 convert through DuckDB's own routines when a floating-point view is what you want. decimal_to_f64 returns None for a width above 38 or a scale above the width: DuckDB would index its powers-of-ten table out of bounds.

Wide integers

HUGEINT is { lower: u64, upper: i64 } and UHUGEINT is two u64s. read_i128 / write_i128 and read_u128 / write_u128 handle the halves; datetime::hugeint_to_f64, f64_to_hugeint, uhugeint_to_f64 and f64_to_uhugeint convert through DuckDB's own routines, so they round as DuckDB does.