Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions docs/rules/timestamp_without_tz.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/pgsleuth/checkers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
redundant_index,
sequence_drift,
three_state_boolean,
timestamp_without_tz,
varchar_length,
)
63 changes: 63 additions & 0 deletions src/pgsleuth/checkers/timestamp_without_tz.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 43 additions & 0 deletions tests/checkers/test_timestamp_without_tz.py
Original file line number Diff line number Diff line change
@@ -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 == []