From a6b3ecb73e223b7b13a3229f01147b93d3b92859 Mon Sep 17 00:00:00 2001 From: Aleksey Smaga Date: Thu, 30 Apr 2026 19:44:06 +0200 Subject: [PATCH] feat(checkers): add timestamp_without_tz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags columns typed as 'timestamp' (without time zone). The non-tz variant stores wall-clock digits with no timezone awareness; values written from different timezones merge incorrectly, DST transitions corrupt local times, and timezone changes don't migrate the data. timestamptz is what teams almost always want. Severity: warning. The cost is real and accumulates (silent timezone bugs surface at DST or on a server move), but the schema isn't currently broken — fits warning per docs/severity.md. Tests cover: clean (timestamptz), positive (timestamp), mixed (only the timestamp column flagged), dropped column ignored. 16/16 against PG 10/13/15/17. --- README.md | 1 + docs/rules/timestamp_without_tz.md | 78 +++++++++++++++++++ src/pgsleuth/checkers/__init__.py | 1 + src/pgsleuth/checkers/timestamp_without_tz.py | 63 +++++++++++++++ tests/checkers/test_timestamp_without_tz.py | 43 ++++++++++ 5 files changed, 186 insertions(+) create mode 100644 docs/rules/timestamp_without_tz.md create mode 100644 src/pgsleuth/checkers/timestamp_without_tz.py create mode 100644 tests/checkers/test_timestamp_without_tz.py diff --git a/README.md b/README.md index 705ddb7..684ba91 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Exit codes: `0` = clean, `1` = issues found, `2` = error. | [`sequence_drift`](docs/rules/sequence_drift.md) | Sequences whose `nextval` would collide with rows already in the table. | | [`three_state_boolean`](docs/rules/three_state_boolean.md) | Boolean columns without `NOT NULL` (true / false / null is rarely intended). | | [`json_over_jsonb`](docs/rules/json_over_jsonb.md) | Columns typed as `json`; `jsonb` supports indexing and is faster on read. | +| [`timestamp_without_tz`](docs/rules/timestamp_without_tz.md) | Columns typed as `timestamp` (without time zone) — silent timezone bugs. | | [`varchar_length`](docs/rules/varchar_length.md) | `varchar(N)` columns where `text` is equivalent in Postgres and avoids the cap. | Every rule has a dedicated page covering rationale, examples, fix SQL, and "when to ignore." Each reported `Issue` carries a `docs_url` pointing at the rule's page, plus a severity (`info` / `warning` / `error`), a fully-qualified object name, a human-readable message, and a suggested fix. diff --git a/docs/rules/timestamp_without_tz.md b/docs/rules/timestamp_without_tz.md new file mode 100644 index 0000000..a41e800 --- /dev/null +++ b/docs/rules/timestamp_without_tz.md @@ -0,0 +1,78 @@ +# timestamp_without_tz + +> **Severity:** warning +> Columns typed as `timestamp` (without time zone) drop timezone information. + +## What it catches + +Any column whose type is `timestamp without time zone` (the SQL standard's `timestamp`, and Postgres' default when you write just `timestamp`). The other timestamp type — `timestamp with time zone` (`timestamptz`) — is **not** flagged. + +```sql +CREATE TABLE events ( + id bigserial PRIMARY KEY, + occurred_at timestamp, -- ⚠️ flagged + created_at timestamptz -- ok +); +``` + +## Why it matters + +The two types sound similar but represent different things: + +- **`timestamptz`** stores the moment as UTC internally; on read it converts to the session's timezone. The value `2026-01-15 10:00:00 UTC` and `2026-01-15 11:00:00 Europe/Berlin` are the same instant — the database knows. +- **`timestamp` (without time zone)** stores wall-clock digits with **no timezone awareness**. The value `2026-01-15 10:00:00` means whatever the writer thought it meant. + +The bugs this introduces are silent and often only surface when something changes: + +### 1. Values written from different timezones merge incorrectly + +Two services or two operators writing to the same column from different timezones produce values that *look* comparable but aren't. Sorting them, comparing them, or computing intervals across them gives wrong answers — and the database happily reports the wrong answer with no error. + +### 2. Daylight-saving transitions corrupt local times + +`'2025-03-30 02:30:00'` written into a `timestamp` column on the day Europe springs forward is a moment that **never existed**. `timestamptz` rejects or normalizes it; `timestamp` stores it as-is and you reread garbage on autumn comparisons. + +### 3. Reads are session-timezone-dependent (for `timestamptz`) or not (for `timestamp`) + +Most frameworks and ORMs assume `timestamptz` semantics. Mixing the two types in one schema means some columns convert on read and some don't, and developers learn the difference by debugging production at 3 AM. + +### 4. Timezone changes (`SET TIMEZONE`, server move, container redeploy) don't migrate the data + +Move your application from `UTC` to `Europe/Berlin` for any reason and every existing `timestamp` value now means something different than what was written. `timestamptz` is unaffected. + +The rule of thumb: **if the value represents a real moment in time** — created_at, updated_at, occurred_at, expires_at, scheduled_for — use `timestamptz`. The very narrow case for `timestamp without time zone` is "wall clock" data: a 09:00 alarm that should fire at 09:00 local time *wherever the user is right now*, even if they cross timezones. That's rare. + +## How to fix + +Convert in place. The `USING` clause is required; pick the timezone that the *existing values* were written in: + +```sql +ALTER TABLE events + ALTER COLUMN occurred_at TYPE timestamptz + USING occurred_at AT TIME ZONE 'UTC'; +``` + +If you're not sure what timezone existing values represent, **stop and find out** before migrating. `AT TIME ZONE` reinterprets them — the wrong source timezone shifts every value silently. + +For very large tables, the `ALTER COLUMN ... TYPE` rewrite takes a full ACCESS EXCLUSIVE lock for the duration. Plan accordingly: copy-table-and-swap is usually safer than an in-place rewrite on a hot table. + +## When to ignore + +Suppress the rule for a specific column if it really is wall-clock data with no timezone semantics: + +```toml +[pgsleuth] +exclude_tables = ["^calendar_events$"] +``` + +Or, if your team has a project-wide reason to use `timestamp without time zone` (rare), disable it: + +```toml +[pgsleuth.checkers.timestamp_without_tz] +enabled = false +``` + +## See also + +- [PostgreSQL — Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html) +- [Don't Do This — `timestamp (without time zone)`](https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_timestamp_.28without_time_zone.29) diff --git a/src/pgsleuth/checkers/__init__.py b/src/pgsleuth/checkers/__init__.py index a122b8d..d4ea1a7 100644 --- a/src/pgsleuth/checkers/__init__.py +++ b/src/pgsleuth/checkers/__init__.py @@ -12,5 +12,6 @@ redundant_index, sequence_drift, three_state_boolean, + timestamp_without_tz, varchar_length, ) diff --git a/src/pgsleuth/checkers/timestamp_without_tz.py b/src/pgsleuth/checkers/timestamp_without_tz.py new file mode 100644 index 0000000..6c7504d --- /dev/null +++ b/src/pgsleuth/checkers/timestamp_without_tz.py @@ -0,0 +1,63 @@ +"""Columns typed as `timestamp` (without time zone) — almost always a bug. + +`timestamp without time zone` stores a wall-clock value with no awareness of +which timezone produced it. Two services writing to the same column from +different timezones, or a single service migrating between regions, will +silently produce values whose meaning depends on context the database +doesn't capture. `timestamptz` ("timestamp with time zone") stores the +moment in UTC and converts on display — almost always what teams actually +want. +""" + +from __future__ import annotations + +from typing import ClassVar + +from pgsleuth.checkers.base import Issue, RowChecker, Severity, register +from pgsleuth.context import CheckerContext + +_SQL = """ +SELECT + n.nspname AS schema, + c.relname AS table, + a.attname AS column +FROM pg_attribute a +JOIN pg_class c ON c.oid = a.attrelid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relkind IN ('r', 'p') + AND a.attnum > 0 + AND NOT a.attisdropped + AND a.atttypid = 'timestamp without time zone'::regtype + {schema_filter} +ORDER BY n.nspname, c.relname, a.attname; +""" + + +class TimestampWithoutTz(RowChecker): + name: ClassVar[str] = "timestamp_without_tz" + description: ClassVar[str] = ( + "Columns typed as `timestamp` (without time zone) drop timezone information." + ) + default_severity: ClassVar[Severity] = Severity.WARNING + sql: ClassVar[str] = _SQL + + def check_row(self, ctx: CheckerContext, row: dict) -> Issue | None: + obj = f"{row['schema']}.{row['table']}.{row['column']}" + return self.issue( + ctx, + object_type="column", + object_name=obj, + message=( + f"Column {obj} is `timestamp without time zone`; " + f"use `timestamptz` to avoid silent timezone bugs." + ), + suggestion=( + f"ALTER TABLE {row['schema']}.{row['table']} " + f"ALTER COLUMN {row['column']} TYPE timestamptz " + f"USING {row['column']} AT TIME ZONE 'UTC'; " + f"-- adjust the source timezone if existing values are not UTC" + ), + ) + + +register(TimestampWithoutTz) diff --git a/tests/checkers/test_timestamp_without_tz.py b/tests/checkers/test_timestamp_without_tz.py new file mode 100644 index 0000000..9e77ac8 --- /dev/null +++ b/tests/checkers/test_timestamp_without_tz.py @@ -0,0 +1,43 @@ +from pgsleuth.checkers.timestamp_without_tz import TimestampWithoutTz + + +def test_clean_when_timestamptz(ctx, conn, schema): + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, occurred_at timestamptz)") + + issues = [i for i in TimestampWithoutTz().run(ctx) if i.object_name.startswith(schema)] + assert issues == [] + + +def test_flags_timestamp_without_tz(ctx, conn, schema): + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, occurred_at timestamp)") + + issues = [i for i in TimestampWithoutTz().run(ctx) if i.object_name.startswith(schema)] + assert len(issues) == 1 + assert issues[0].object_name == f"{schema}.t.occurred_at" + + +def test_flags_only_timestamp_columns(ctx, conn, schema): + with conn.cursor() as cur: + cur.execute( + "CREATE TABLE t (" + " id bigserial PRIMARY KEY," + " occurred_at timestamp," + " created_at timestamptz," + " date_only date" + ")" + ) + + issues = [i for i in TimestampWithoutTz().run(ctx) if i.object_name.startswith(schema)] + assert len(issues) == 1 + assert issues[0].object_name == f"{schema}.t.occurred_at" + + +def test_dropped_column_is_ignored(ctx, conn, schema): + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, occurred_at timestamp)") + cur.execute("ALTER TABLE t DROP COLUMN occurred_at") + + issues = [i for i in TimestampWithoutTz().run(ctx) if i.object_name.startswith(schema)] + assert issues == []