Skip to content

Build1 publisher3 min readPublished

Your ORM picked the wrong timestamp type, and a migration linter cannot find it

Rails' t.datetime and Prisma's DateTime both emit PostgreSQL's naive timestamp. The offending statements merged years ago, which is exactly why line-level tooling never sees them.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • Rails' t.datetime and Prisma's DateTime both emit the naive timestamp type on PostgreSQL unless the developer goes out of the way to change it, which is how a schema ends up with hundreds of columns nobody chose.
  • Both timestamp and timestamptz occupy 8 bytes, so the correct one is free.
  • A timestamp column behaves perfectly for years, then produces an hour of wrong answers at a daylight saving boundary, or shifts an entire reporting dashboard the day someone changes the server's time zone; nothing in the schema looks wrong at any point because nothing in the schema is invalid.
  • A varchar(20) that should have been text announces itself the first time a value does not fit.
  • PostgreSQL's wiki has a page called Don't Do This where 'don't use timestamp (without time zone)' sits alongside its advice against char(n), money and serial.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A post on dev.to from the team behind Schemity, a desktop ERD tool, argues that most PostgreSQL schemas carry the wrong timestamp type in bulk, and that the cause is not disagreement but defaults: Rails' `t.datetime` and Prisma's `DateTime` both emit the naive type unless you go out of your way [1]. It matters because both types occupy 8 bytes, so the wrong choice bought nothing [2], and because nothing about the resulting column ever looks invalid. That is the awkward property of this defect class. A `varchar(20)` that should have been `text` announces itself the first time a value does not fit [4]. A `timestamp` column behaves perfectly for years and then produces an hour of wrong answers at a daylight saving boundary, or shifts an entire reporting dashboard the day someone changes the server's time zone [3]. PostgreSQL's own wiki puts "don't use timestamp (without time zone)" on its Don't Do This page, alongside its advice against char(n), money and serial [5]. The wiki's framing is the useful one: `timestamp` stores "a date and time you give it", compared to a picture of a calendar and a clock, while `timestamptz` "records a single moment in time" and does the right thing with arithmetic between values entered in different zones [6]. The standard objection is that `timestamptz` stores a zone and therefore costs something. It does not. The PostgreSQL date/time documentation states that an input string with an explicit zone "will be converted to UTC using the appropriate offset for that time zone", and that "in either case, the value is stored internally as UTC, and the originally stated or assumed time zone is not retained" [7]. The types table gives both variants 8 bytes, both spanning 4713 BC to 294276 AD at 1 microsecond resolution [8]. The storage delta between right and wrong is zero [20]. If you genuinely need to know that a booking was entered in Europe/Berlin, that is a second column holding the zone name, and a modelling decision rather than a type choice [9]. The decision rule is short. `created_at`, `deleted_at`, `published_at`, `last_seen_at`, every audit column and every event time are instants, and belong in `timestamptz` [10]. A shop that opens at 09:00 in whatever zone it stands in, a birthday, an alarm a user wants at 07:00 in any country: those are wall-clock readings, and `timestamp` is the correct type for them [11]. The second set is much smaller than the number of `timestamp` columns in a typical database, which is the tell [12]. Rails is the documented case. `t.datetime` translates to `timestamp without time zone` on PostgreSQL [13]. The `datetime_type` setting that lets you switch it to `:timestamptz` arrived in Rails 7.0, more than six years after the issue titled "standard migrations will generate a 'timestamp without timezone' field" was opened on 4 August 2015, whose complaint was that `AT TIME ZONE` arithmetic then quietly uses the database server's zone rather than the intended one [14] [15]. The default itself has not changed, so an application generated today still produces naive columns [16]. Which is the tooling gap underneath all of this: a migration linter inspects statements as they land, and these statements landed years ago, so by the time the cost appears there is nothing left to lint [17]. Schemity's stated answer is to lint the whole open ERD instead, marking each offending field row in the margin, with the conversion routed through a migration SQL diff you review first [18]. That is a vendor claim on a vendor blog, disclosed as such by the author [19], and worth treating as a description of one approach rather than evidence it works.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories