Skip to content

Bug: Bun.SQL Date values returned from DATETIME/TIMESTAMP columns deserialized as local time instead of UTC #29208

@heiwen

Description

@heiwen

Version

Bun v1.3.11

Summary

When reading DATETIME or TIMESTAMP columns from MySQL (or TIMESTAMP from PostgreSQL), Bun.SQL constructs the returned JavaScript Date object using the local-time Date constructor (new Date(year, month-1, day, hour, ...)) instead of Date.UTC(...). On machines where the process timezone is not UTC, the resulting Date has the wrong UTC epoch — off by the machine's UTC offset.

The bug is in deserialization (reading). The stored value is correct; the mismatch is introduced when Bun converts the binary DATETIME bytes returned by the server into a JavaScript Date.

Confirmed against:

  • MySQL 8 — DATETIME and TIMESTAMP columns both affected
  • PostgreSQL 17 — TIMESTAMP (without time zone) affected; TIMESTAMPTZ (with time zone) returns correct values, demonstrating the PostgreSQL binary path is capable of the correct behaviour

Reproducer

import { SQL } from 'bun';

// Must run with a non-UTC timezone to expose the bug:
//   TZ=Asia/Bangkok bun run repro.ts
//
// Start databases:
//   docker run --rm -p 3307:3306 -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=test mysql:8
//   docker run --rm -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:17-alpine

const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log(`Process timezone: ${tz}`);

// On a UTC+7 machine this Date has UTC epoch 2024-01-15T05:30:45.678Z
const d = new Date(2024, 0, 15, 12, 30, 45, 678);
console.log(`Sent UTC epoch:   ${d.toISOString()}`);

// ── MySQL 8 ──────────────────────────────────────────────────────────────

const mysql = new SQL('mysql://root:test@localhost:3307/test', {
  max: 1, ssl: false,
});

await mysql.unsafe('DROP TABLE IF EXISTS ts_test');
await mysql.unsafe(`
  CREATE TABLE ts_test (
    id   INT PRIMARY KEY,
    ts   DATETIME(3),
    tstz TIMESTAMP(3)
  )
`);

await mysql`INSERT INTO ts_test (id, ts, tstz) VALUES (${1}, ${d}, ${d})`;

const [row] = await mysql.unsafe(
  'SELECT ts, tstz FROM ts_test WHERE id = 1',
) as any[];

console.log('\nMySQL 8');
console.log(`  DATETIME  returned: ${(row.ts   as Date).toISOString()}`);
console.log(`  TIMESTAMP returned: ${(row.tstz as Date).toISOString()}`);
// Expected both: 2024-01-15T05:30:45.678Z
// Actual on UTC+7: 2024-01-14T22:30:45.678Z  (-7h)

await mysql.unsafe('DROP TABLE IF EXISTS ts_test');
mysql.close();

// ── PostgreSQL 17 ────────────────────────────────────────────────────────

const pg = new SQL('postgres://postgres:test@localhost:5433/postgres', {
  max: 1, ssl: false,
});

await pg.unsafe('DROP TABLE IF EXISTS ts_test');
await pg.unsafe(`
  CREATE TABLE ts_test (
    id   INT PRIMARY KEY,
    ts   TIMESTAMP(3),           -- without time zone (affected)
    tstz TIMESTAMPTZ(3)          -- with time zone    (correct)
  )
`);

await pg`INSERT INTO ts_test (id, ts, tstz) VALUES (${1}, ${d}, ${d})`;

const [pgRow] = await pg.unsafe(
  'SELECT ts, tstz FROM ts_test WHERE id = 1',
) as any[];

console.log('\nPostgreSQL 17 (prepared statements)');
console.log(`  TIMESTAMP   returned: ${(pgRow.ts   as Date).toISOString()}`);
console.log(`  TIMESTAMPTZ returned: ${(pgRow.tstz as Date).toISOString()}`);
// Expected both: 2024-01-15T05:30:45.678Z
// TIMESTAMP on UTC+7:   2024-01-14T22:30:45.678Z  (-7h)  ← bug
// TIMESTAMPTZ on UTC+7: 2024-01-15T05:30:45.678Z  (0h)   ← correct

await pg.unsafe('DROP TABLE IF EXISTS ts_test');
pg.close();

Output on a UTC+7 machine

Process timezone: Asia/Bangkok
Sent UTC epoch:   2024-01-15T05:30:45.678Z

MySQL 8
  DATETIME  returned: 2024-01-14T22:30:45.678Z   ← 7 hours behind
  TIMESTAMP returned: 2024-01-14T22:30:45.678Z   ← 7 hours behind

PostgreSQL 17 (prepared statements)
  TIMESTAMP   returned: 2024-01-14T22:30:45.678Z ← 7 hours behind
  TIMESTAMPTZ returned: 2024-01-15T05:30:45.678Z ← correct

Output on a UTC machine (no bug)

Process timezone: UTC
Sent UTC epoch:   2024-01-15T12:30:45.678Z

MySQL 8
  DATETIME  returned: 2024-01-15T12:30:45.678Z   ✓
  TIMESTAMP returned: 2024-01-15T12:30:45.678Z   ✓

PostgreSQL 17 (prepared statements)
  TIMESTAMP   returned: 2024-01-15T12:30:45.678Z ✓
  TIMESTAMPTZ returned: 2024-01-15T12:30:45.678Z ✓

Expected behaviour

The returned Date has the same UTC epoch as the sent Date, regardless of the machine's timezone. The stored value is correct (verified by SELECT DATE_FORMAT(ts, ...) / to_char(ts, ...)); only the Bun-side deserialization is wrong.

Actual behaviour

On any machine where the process timezone is not UTC, the returned Date epoch is off by the UTC offset. The offset equals the machine's UTC offset, consistent with Bun converting the returned binary DATETIME components via the local-time constructor new Date(year, month-1, day, hour, min, sec, ms) instead of Date.UTC(year, month-1, day, hour, min, sec, ms).

Affected columns / types

Driver Column type Affected
MySQL DATETIME ✗ -7h
MySQL TIMESTAMP ✗ -7h
PG TIMESTAMP ✗ -7h
PG TIMESTAMPTZ ✓ 0h

The PostgreSQL TIMESTAMPTZ result shows the binary path can handle this correctly and confirms the bug is in the timezone-unaware deserialisation branch.

Notes

  • The bug is masked by bun test, which forces TZ=UTC for the test runner process. Test suites that insert and verify timestamps pass under bun test on any timezone because local time equals UTC during the run.
  • TZ=UTC bun run repro.ts produces delta=0 on all columns, confirming the bug only manifests when the process timezone differs from UTC.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions