Skip to content

Extended protocol sends TIMESTAMPTZ and DATE as text instead of binary format #2318

@johannesdb

Description

@johannesdb

Description

When using the PostgreSQL extended query protocol, DoltgreSQL sends TIMESTAMPTZ and DATE column values as text strings instead of the expected binary format. This breaks typed PostgreSQL clients that expect the standard binary encoding.

TIMESTAMPTZ: Expected 8-byte binary (i64, microseconds since 2000-01-01 UTC), but DoltgreSQL sends a 29-byte text string like 2026-01-15 10:30:00.076264+00.

DATE: Expected 4-byte binary (i32, days since 2000-01-01), but DoltgreSQL sends a 10-byte text string like 2026-01-15.

Reproduction

// Table with TIMESTAMPTZ and DATE columns:
// CREATE TABLE events (
//     id UUID PRIMARY KEY,
//     created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
//     event_date DATE
// );

#[derive(sqlx::FromRow)]
struct Event {
    id: Uuid,
    created_at: DateTime<Utc>,  // expects 8-byte binary
    event_date: Option<NaiveDate>,  // expects 4-byte binary
}

// This fails — sqlx tries to decode text bytes as binary:
let event = sqlx::query_as::<_, Event>("SELECT * FROM events WHERE id = $1")
    .bind(some_id)
    .fetch_one(&pool)
    .await?;

SQLx's DateTime<Utc> decoder expects 8 bytes (i64 microseconds) but receives 29 bytes of text. Similarly, NaiveDate expects 4 bytes (i32 days) but receives 10 bytes of text.

Workaround

We use newtype wrappers that detect the format by buffer size and decode accordingly:

impl<'r> sqlx::Decode<'r, sqlx::Postgres> for DoltTimestamp {
    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
        let bytes = value.as_bytes()?;
        if bytes.len() > 8 {
            // DoltgreSQL text format
            let s = std::str::from_utf8(bytes)?;
            let dt = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%#z")?;
            Ok(DoltTimestamp(dt.with_timezone(&Utc)))
        } else if bytes.len() == 8 {
            // Standard PostgreSQL binary
            let micros = i64::from_be_bytes(bytes.try_into()?);
            let pg_epoch = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap()
                .and_hms_opt(0, 0, 0).unwrap().and_utc();
            Ok(DoltTimestamp(pg_epoch + Duration::microseconds(micros)))
        } else {
            Err("unexpected buffer size".into())
        }
    }
}

This works but requires wrapping every DateTime<Utc> and NaiveDate field in our entire codebase (30+ structs).

Expected behavior

The extended query protocol should send TIMESTAMPTZ as 8-byte big-endian i64 (microseconds since 2000-01-01 00:00:00 UTC) and DATE as 4-byte big-endian i32 (days since 2000-01-01), matching standard PostgreSQL binary format.

Environment

  • DoltgreSQL: dolthub/doltgresql:latest (Docker), tested on 0.54.4 through 0.55.0
  • Client: SQLx 0.8 (Rust) via extended query protocol

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions