A MySQL TIMESTAMP column stores the same bytes as a DATETIME column when the session timezone is UTC. Change the session timezone, and the same insert produces different bytes in each type. MySQL does not warn you. It just stores whatever you give it, interpreted through whatever timezone the connection happens to be using.
This isn't a MySQL bug. It's the correct behavior of both types. The problem is that most developers don't know the difference, because the difference only matters when something goes wrong.
Timezone bugs share a pattern: the code looks correct, the tests pass, and the production data is wrong. Three specific gaps cause most of them.
The Storage Layer Is Not Neutral
MySQL has TIMESTAMP and DATETIME. They look interchangeable. They are not.
TIMESTAMP converts values to UTC for storage and back to the session timezone on retrieval. Internally it stores a Unix timestamp (seconds since epoch), and the conversion happens on every read and write based on the session's time_zone variable. DATETIME stores the literal bytes you insert, with no conversion at all.
SET time_zone = '+05:00';
INSERT INTO events (ts_col, dt_col) VALUES ('2026-07-20 12:00:00', '2026-07-20 12:00:00');
SET time_zone = '+00:00';
SELECT ts_col, dt_col FROM events;
-- ts_col → 2026-07-20 07:00:00 (converted back to UTC)
-- dt_col → 2026-07-20 12:00:00 (stored verbatim)
If the application sets the session to UTC and the connection pool reuses connections without resetting the session, a TIMESTAMP read from a previous session can be off by hours. I have debugged a staging environment where the ORM configuration set time_zone = '+00:00' in one file and the database server's default was SYSTEM, which happened to be +02:00. The application worked for users in Europe and failed for everyone else.
PostgreSQL avoids this by naming them differently. TIMESTAMP and TIMESTAMPTZ are distinct types in the catalog, and the query planner rejects operations that mix them implicitly. You still need to configure the timezone, but you cannot accidentally treat a TIMESTAMPTZ column as a wall-clock time.
SELECT now() AT TIME ZONE 'UTC'; -- explicit conversion
SELECT scheduled_at AT TIME ZONE 'America/New_York' FROM events;
The rule "store UTC" is correct, but it depends on every layer of the stack agreeing on what UTC means. MySQL defaults to the server's local timezone. Node.js MySQL drivers default to whatever the server reports. Python's datetime.now() uses the system clock. Each layer has its own default, and they almost never match.
Offsets Expire, Identifiers Don't
IANA timezone identifiers like "Europe/Madrid" encode two things: the base offset from UTC and the rules for when DST applies. The offset "UTC+2" encodes neither of those. It is a snapshot of the identifier at a specific moment. Store the offset in June and use it in January, and the time is wrong by one hour.
This matters in production because DST transition bugs do not crash the application. They shift data silently.
from datetime import datetime
from zoneinfo import ZoneInfo
# Session at 2:15 PM in Madrid
session_start = datetime(2026, 3, 29, 14, 15, tzinfo=ZoneInfo("Europe/Madrid"))
# Store in UTC
utc_start = session_start.astimezone(ZoneInfo("UTC"))
# → 2026-03-29 12:15:00+00:00
# Wrong: store the offset instead
offset = "+02:00"
# Two weeks later DST changes to +02:00 → +01:00
# All calculations using this offset are now shifted
The DST problem extends beyond display logic. Scheduled jobs are the most common casualty. A cron job set to run at 2:30 AM server time runs twice on the day DST ends and zero times on the day it starts. System cron uses the server's local time, and if the server observes DST, the schedule shifts.
# Every night at 2:30 AM server time — breaks on DST transitions
30 2 * * * /usr/bin/backup.sh
# Fixed: run at 2:30 AM UTC regardless of server locale
30 2 * * * TZ=UTC /usr/bin/backup.sh
Setting TZ=UTC in the crontab or using CRON_TZ is the fix, but it is easy to miss because the schedule works correctly for eleven months of the year. The logs show a gap or a double run, and the natural assumption is a process crash, not a timezone issue.
The root cause is that timezone identifiers and offsets belong to different abstractions, but APIs and SDKs often conflate them. A Stripe webhook payload includes a created field as a Unix timestamp (absolute) and a timezone field as a string like "America/New_York" (identifier). That is correct. A different API might return "timezone": "-05:00" (offset), which looks similar but loses the DST information. The difference between an identifier and an offset is invisible in a JSON response and catastrophic six months later.
The Number Itself Is Ambiguous
Unix timestamps are seconds since January 1, 1970, because in 1970, 32 bits could represent 68 years of seconds (until 2038). JavaScript adopted milliseconds so that Date.now() fits values up to 275,760 years. Two different units for the same concept.
// API returns: 1758300000 (seconds)
// JavaScript Date expects milliseconds
const ms = 1758300000 * 1000; // correct
const wrong = new Date(1758300000); // January 20, 1970
// Unknown resolution — defensive parsing
function normalizeTs(value) {
if (value > 1e11) return value; // already ms
if (value > 1e8) return value * 1000; // seconds → ms
throw new Error(`Ambiguous timestamp: ${value}`);
}
The boundary between seconds and milliseconds has shifted over time. Before ~2015, most APIs returned seconds. After 2020, many modern APIs return milliseconds or microseconds. The timestamp converter on this site accepts both formats and identifies which one each value matches. Useful when you paste a number from an API response and need to confirm the scale.
The real problem is not the conversion logic. It is that the conversion is opt-in. Every API client that reads the timestamp field must remember to call the conversion function. One client does. Another reads the raw JSON and passes the number directly to new Date(). The bug appears in production, not in tests, because test data uses small numbers that happen to produce valid dates in both scales.
The fix is to normalize at the deserialization boundary, not in the application logic. Parse the timestamp once, convert it to a standard representation (RFC 3339 / ISO 8601 string in UTC), and pass that through the rest of the system. SQLite stores ISO 8601 strings natively. PostgreSQL's TIMESTAMPTZ returns them. The fewer places that interpret raw epoch numbers, the fewer places that can misinterpret the unit.
If you remember one thing: store UTC, use IANA timezone identifiers, and never trust a raw number without knowing its unit.