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.
Version
Bun v1.3.11
Summary
When reading
DATETIMEorTIMESTAMPcolumns from MySQL (orTIMESTAMPfrom PostgreSQL), Bun.SQL constructs the returned JavaScriptDateobject using the local-timeDateconstructor (new Date(year, month-1, day, hour, ...)) instead ofDate.UTC(...). On machines where the process timezone is not UTC, the resultingDatehas 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:
DATETIMEandTIMESTAMPcolumns both affectedTIMESTAMP(without time zone) affected;TIMESTAMPTZ(with time zone) returns correct values, demonstrating the PostgreSQL binary path is capable of the correct behaviourReproducer
Output on a UTC+7 machine
Output on a UTC machine (no bug)
Expected behaviour
The returned
Datehas the same UTC epoch as the sentDate, regardless of the machine's timezone. The stored value is correct (verified bySELECT 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
Dateepoch 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 constructornew Date(year, month-1, day, hour, min, sec, ms)instead ofDate.UTC(year, month-1, day, hour, min, sec, ms).Affected columns / types
DATETIMETIMESTAMPTIMESTAMPTIMESTAMPTZThe PostgreSQL
TIMESTAMPTZresult shows the binary path can handle this correctly and confirms the bug is in the timezone-unaware deserialisation branch.Notes
bun test, which forcesTZ=UTCfor the test runner process. Test suites that insert and verify timestamps pass underbun teston any timezone because local time equals UTC during the run.TZ=UTC bun run repro.tsproduces delta=0 on all columns, confirming the bug only manifests when the process timezone differs from UTC.