PG.L6.TIMESTAMP_NO_TZ — Timestamp column with no time zone
- Category: idiom
- Level: 6
- Confidence: deterministic
- Downtime class: none — nothing here is a schema change
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL
What the column actually stores
timestamp without time zone keeps the digits it was given. Nothing else. There is no clock
attached, so the value only means something if every writer and every reader already agree on one —
and that agreement lives in your head, not in the database.
Two workers in different regions write a row a second apart. The column stores them an hour apart,
and nothing in it says so. The value then survives a dump, a restore onto a server with a different
TimeZone, and a read replica in a third — unchanged each time, and meaning something different
each time.
timestamptz does not store a zone either, despite the name. It stores the instant: PostgreSQL
converts the value to UTC on the way in using the session's zone, and converts it back on the way
out. Two moments a second apart stay a second apart, whoever wrote them and wherever they are read.
The failure is quiet, and that is why it is worth a rule. Nothing errors. Nothing is corrupted at write time. The mistake surfaces months later as a report that is off by an hour twice a year, on rows written by one region, around a daylight-saving boundary.
Flagged
Schema::create('orders', function (Blueprint $table) {
$table->timestamp('placed_at');
});
Laravel's timestamp() produces exactly this type, which is why this rule sits at level 6: it will
have something to say about almost every Laravel schema, and that is a conversation to opt into
rather than a build to fail.
Preferred
Schema::create('orders', function (Blueprint $table) {
$table->timestampTz('placed_at');
});
Migrating an existing column converts the values rather than reinterpreting them:
ALTER TABLE orders
ALTER COLUMN placed_at TYPE timestamptz
USING placed_at AT TIME ZONE 'UTC';
The USING clause is the part that matters. It states which clock the existing digits were on. Get
it wrong and every historical row shifts — so name the zone the application was actually writing in,
not the one the server happens to have today.
What is not flagged
date carries no instant to convert, so it is never reported. Neither is timestamptz or timetz.
A time without time zone is reported, with timetz as its counterpart — but it is the weaker
case: a time of day with no date has no instant either, and it is very often a deliberate wall-clock
value.
The exception this rule cannot see
A zone-less column is sometimes exactly right. A branch that opens at 09:00 local opens at 09:00 in every city; converting that to an instant would be the bug, not the fix. Business hours, recurring appointment times and opening calendars are genuine wall-clock data.
This rule reads the catalog, not your domain, so it cannot tell one from the other. Where the zone-less type is deliberate, ignore it and keep the decision visible:
'audit' => [
'ignore' => [
'pairs' => [
['rule' => 'PG.L6.TIMESTAMP_NO_TZ', 'objects' => ['public.opening_hours']],
],
],
],
The finding stays counted and listed under what hid it, rather than disappearing from the report.
One finding per table, naming every column
A table with three zone-less columns produces one finding that names all three, not three findings. That is deliberate: a catalog finding is located at the object, so three findings about one table would be indistinguishable from each other — and it is also the better report. Which columns to convert is one decision about one table, not three separate ones.