Your schema is a contract
Your database schema is an interface other teams read — clear types and unambiguous names keep ETL jobs and analysts from guessing wrong.
Your application is rarely the only thing that reads your database. ETL jobs, analytics pipelines, a data-warehouse sync, the data scientist three teams over — all of them read your tables, and none of them carry the context you keep in your head. You may know that created_at is really server-local time from a box that ran on US/Eastern until 2019; they see a column name and a type, and they trust both. In a multi-consumer world the schema is the documentation — and the type is the only documentation the database actually enforces and that can never drift out of date. This is the producer side of 3rd-party data: there you are the wary consumer of someone else’s timestamps; here you are the producer everyone else depends on.
The cost lands downstream, and it’s silent
Inside your application, a time bug tends to announce itself: a wrong time on a screen, a failed parse, an exception. Downstream, the same bug is silent and plausible. A naive timestamp read as UTC shifts every west-coast event across the day boundary, and revenue quietly lands in the wrong day’s bucket. A zone misread pushes a cohort off by one day for every user east of UTC. Naive timestamps that all collapse to 00:00 produce a “midnight spike” that looks like real user behavior. Nothing throws; the numbers simply come out wrong and go on to inform a decision. Clarity at the source is what prevents this — the alternative is every consumer independently re-deriving, and re-guessing, what your columns mean.
Let the type carry the meaning
The first line of the contract is the type. Reach for the one that actually models the value: a zone-aware instant type for events (timestamptz), a date for civil dates, an explicit civil-time-plus-zone for wall-clock intent. A reader who sees an instant type knows that comparisons across rows are meaningful and that the value has a single, unambiguous basis. This page will not re-derive how to pick the type — Store vs display covers modeling the value with the type that fits it, and the storage pages cover what each engine offers. The point here is only that the type is read by people who are not you.
When the type can’t say it, the name must
Sometimes the type cannot carry the whole meaning — your engine has no zone-aware type, or you are deliberately storing a wall-clock value. The meaning does not disappear; it moves to the next-best carriers: the name and a comment stating the read protocol. A _utc suffix when the engine cannot guarantee the basis, a local tag for wall-clock values, units in the name for raw numbers. Naming is a discipline of its own — see Naming time fields for the full conventions.
Pitfall: A column whose type or units contradict its name — a date column that is actually a timestamptz, or a bare integer ts whose seconds-or-milliseconds units are undocumented. A downstream WHERE date = '2026-06-05' silently drops or misplaces rows at the UTC-midnight boundary, and a JOIN across mismatched units matches nothing. The query runs; the result is quietly wrong.
Go deeper: ETL round-trips, partition keys, and normalizing at the boundary
Round-trips can double a shift. A timestamp without time zone column exported to Parquet or CSV and re-loaded by Spark or pandas is often silently assumed to be UTC on the way out and localized on the way back in — two shifts, not zero. Tools disagree about what a zoneless timestamp means, so a value that survives a round-trip inside one engine can be corrupted crossing between two.
Partition keys derived from instants. Date-partitioned tables often compute the partition from a UTC instant. For users far enough east or west, “their” day and the UTC day differ, so rows land in the wrong partition — and any query that filters by partition silently misses them.
Undocumented epoch units. A bare integer column holding epoch time, with no unit in its name or a comment, is a standing seconds-vs-milliseconds trap for every consumer. See Naming time fields.
Normalize at the boundary. The cheapest fix is to hand consumers one unambiguous representation: normalize to RFC 3339 UTC at the edge of your system so nothing downstream has to re-guess.
See also. Naming time fields, 3rd-party data, Store vs display, Instant vs civil time.