TIMESTAMPTZ vs TIMESTAMP: PostgreSQL 대시보드와 API
PostgreSQL에서 TIMESTAMPTZ와 TIMESTAMP의 차이가 대시보드, API 응답, 시간대 변환 및 서머타임 버그에 어떤 영향을 주는지 설명합니다.

문제의 본질: 한 번 일어난 사건, 여러 해석\n\n사건은 한 번 발생하지만 여러 방식으로 보고됩니다. 데이터베이스가 값을 저장하고, API가 직렬화하고, 대시보드가 집계하며, 각 사용자는 자신 시간대에 맞춰 봅니다. 어느 한 계층이라도 다른 가정을 하면 같은 행이 서로 다른 순간처럼 보일 수 있습니다.\n\n그래서 TIMESTAMPTZ와 TIMESTAMP의 선택은 단순한 타입 선호가 아닙니다. 저장된 값이 특정한 순간(instant)을 나타내는지, 아니면 특정 지역에서만 의미가 있는 벽시계(wall-clock) 시간인지 결정합니다.\n\n보통 먼저 깨지는 부분은 다음과 같습니다: 세일즈 대시보드가 뉴욕과 베를린에서 서로 다른 일별 합계를 보여주거나, 시간별 차트가 DST 변경 시 한 시간이 빠지거나 중복되는 경우, 감사 로그가 두 시스템이 날짜에는 “동의”하지만 실제 순간에 대해서는 순서가 뒤섞여 보이는 경우 등입니다.\n\n간단한 모델만 지켜도 문제를 피할 수 있습니다:\n\n- 저장(Storage): PostgreSQL에 무엇을 저장하고 그 값이 무엇을 의미하는가.\n- 표시(Display): UI, 내보내기, 리포트에서 어떻게 포맷하는가.\n- 사용자 로케일(User locale): 뷰어의 시간대와 달력 규칙(서머타임 포함).\n\n이들을 섞어 쓰면 조용히 보고 버그가 생깁니다. 지원팀이 대시보드에서 “어제 생성된 티켓”을 내보내 API 보고서와 비교하면, 둘 다 그럴듯하지만 한 쪽은 뷰어의 로컬 자정 경계를 썼고 다른 쪽은 UTC를 썼을 수 있습니다.\n\n목표는 간단합니다: 각 시간 값에 대해 두 가지를 분명히 결정하세요. 무엇을 저장할지, 무엇을 보여줄지 결정하세요. 이 명확성은 데이터 모델, API 응답, 대시보드 전반에 걸쳐 유지되어야 모두가 같은 타임라인을 보게 됩니다.\n\n## TIMESTAMP와 TIMESTAMPTZ가 실제로 의미하는 것\n\nPostgreSQL에서 이름은 오해를 불러옵니다. 이름은 무엇이 저장되는지를 설명하는 것처럼 보이지만, 사실은 PostgreSQL이 입력을 어떻게 해석하고 출력을 어떻게 포맷하는지를 설명합니다.\n\nTIMESTAMP(즉 timestamp without time zone)는 단지 달력 날짜와 시각입니다. 예: 2026-01-29 09:00:00. 시간대가 붙어 있지 않습니다. PostgreSQL은 자동으로 변환해주지 않습니다. 다른 시간대에 있는 두 사람이 같은 TIMESTAMP를 읽고 서로 다른 실제 순간으로 해석할 수 있습니다.\n\nTIMESTAMPTZ(즉 timestamp with time zone)는 실제 시점을 나타냅니다. 인스턴트로 생각하세요. PostgreSQL은 내부적으로 정규화(사실상 UTC로)한 뒤 세션의 시간대에 맞춰 표시합니다.\n\n대부분의 깜짝 놀람 뒤에는 이런 동작이 있습니다:\n\n- 입력 시: PostgreSQL은 TIMESTAMPTZ 값을 단일 비교 가능한 순간으로 변환합니다.\n- 출력 시: PostgreSQL은 현재 세션 시간대를 사용해 그 순간을 포맷합니다.\n- TIMESTAMP의 경우: 입력이나 출력에서 자동 변환이 일어나지 않습니다.\n\n작은 예제가 차이를 보여줍니다. 앱이 사용자로부터 2026-03-08 02:30을 받았다고 합시다. 이를 TIMESTAMP 열에 삽입하면 PostgreSQL은 정확히 그 벽시계 값을 저장합니다. 만약 그 지역에서 DST로 인해 그 로컬 시간이 존재하지 않는다면, 보고 단계에서 문제가 발생할 때까지 눈치채지 못할 수 있습니다.\n\nTIMESTAMPTZ로 삽입하면 PostgreSQL은 값을 해석할 시간대가 필요합니다. 예를 들어 2026-03-08 02:30 America/New_York이라면 PostgreSQL은 이를 순간으로 변환합니다(규칙과 값에 따라 오류를 낼 수도 있음). 나중에 런던의 대시보드는 다른 로컬 시간을 보여도 동일한 실시간 순간을 가리킵니다.\n\n흔한 오해 하나: 사람들은 “with time zone”을 보고 PostgreSQL이 원래 사용자의 시간대 라벨을 저장한다고 기대합니다. 그렇지 않습니다. PostgreSQL은 라벨이 아니라 그 순간을 저장합니다. 만약 사용자의 원래 시간대 라벨을 표시해야 한다면(예: “고객 로컬 시간으로 보여주기”), 시간대 정보를 별도의 텍스트 필드로 저장하세요.\n\n## 세션 시간대: 많은 놀라움의 숨은 설정\n\nPostgreSQL에는 당신이 보는 것을 조용히 바꾸는 설정이 있습니다: 세션 시간대입니다. 같은 데이터를 같은 쿼리로 조회해도 세션의 시간대가 다르면 서로 다른 시계 시간을 볼 수 있습니다.\n\n이건 주로 TIMESTAMPTZ에 영향을 줍니다. PostgreSQL은 절대적 순간을 저장한 뒤 세션 시간대에 맞춰 표시합니다. TIMESTAMP는(시간대 없음) 값을 단순한 달력 시간으로 취급해 표시를 위해 이동시키지 않습니다. 그러나 TIMESTAMP를 TIMESTAMPTZ로 변환하거나 시간대 인지 값과 비교할 때 세션 시간대가 문제를 일으킬 수 있습니다.\n\n세션 시간대는 무심코 설정되는 경우가 많습니다: 애플리케이션 시작 설정, 드라이버 파라미터, 커넥션 풀이 이전 세션을 재사용, BI 도구의 기본값, ETL 작업이 서버 로케일을 상속, 또는 개인 SQL 콘솔이 노트북 설정을 사용하는 경우 등입니다.\n\n팀들이 논쟁하는 상황은 이런 식입니다. 사건이 TIMESTAMPTZ 열에 2026-03-08 01:30:00+00으로 저장되어 있다고 가정합시다. America/Los_Angeles 세션의 대시보드는 이를 전날 저녁의 로컬 시간으로 표시할 것이고, API 세션이 UTC이면 다른 시계 시간을 보여줄 것입니다. 차트가 세션 로컬 일 단위로 그룹화하면 일별 합계가 달라질 수 있습니다.\n\nsql\n-- Make your output consistent for a reporting job\nSET TIME ZONE 'UTC';\n\nSELECT created_at, date_trunc('day', created_at) AS day_bucket\nFROM events;\n\n\n리포트를 생성하거나 API 응답을 만드는 모든 곳에서는 시간대를 명시하세요. 연결 시 설정하거나(SET TIME ZONE을 먼저 실행), 기계 출력용 표준(종종 UTC)을 선택하고, “로컬 비즈니스 시간” 리포트는 누군가의 노트북 설정에 의존하지 말고 잡(job) 내부에서 비즈니스 존을 설정하세요. 풀링된 커넥션을 사용한다면 커넥션을 체크아웃할 때 세션 설정을 리셋하세요.\n\n## 대시보드가 깨지는 방식: 그룹화, 버킷, DST 간극\n\n대시보드는 단순해 보입니다: 하루당 주문 수를 세고, 시간별 가입을 보여주고, 주별 비교를 합니다. 문제는 데이터베이스가 하나의 “순간(moment)”만 저장했는데 차트가 그것을 보는 사람에 따라 여러 다른 “날들”로 바꿀 때 시작됩니다.\n\n사용자 로컬 시간대 기준으로 일별 그룹화를 하면 같은 사건이 두 사람에게 다른 날짜로 보일 수 있습니다. 로스앤젤레스에서 23:30에 들어온 주문은 베를린에서는 이미 “다음 날”일 수 있습니다. 그리고 TIMESTAMP에 대해 DATE(created_at)로 그룹화하면 실제 순간이 아닌 벽시계 판독값으로 그룹화하는 것입니다.\n\nDST 주변의 시간별 차트는 더 복잡해집니다. 봄에는 한 로컬 시간이 아예 존재하지 않아 차트에 간극이 생기고, 가을에는 한 로컬 시간이 두 번 발생해 스파이크나 중복된 버킷이 생길 수 있습니다. 이는 쿼리와 대시보드가 어떤 01:30을 의미하는지 다르게 해석할 때 발생합니다.\n\n실용적인 질문: 당신은 실제 순간(real moments)을 차트로 그리는가(변환해도 안전), 아니면 로컬 스케줄 시간(local schedule time)을 그리는가(절대 변환하면 안 됨)? 대시보드는 거의 항상 실제 순간을 원합니다.\n\n### UTC로 그룹화할 때와 비즈니스 시간대로 그룹화할 때\n\n하나의 그룹화 규칙을 선택해 모든 곳(SQL, API, BI 도구)에 적용하세요. 그렇지 않으면 합계가 drift합니다.\n\n글로벌하고 일관된 시계열(시스템 상태, API 트래픽, 글로벌 가입)을 원하면 UTC로 그룹화하세요. “그 날”이 법적·운영적 의미가 있을 때(매장 영업일, SLA, 금융 마감)는 비즈니스 시간대로 그룹화하세요. 개인화가 비교 가능성보다 중요할 때만 뷰어의 시간대로 그룹화하세요(개인 활동 피드 등).\n\n일관된 “비즈니스 일” 그룹화 패턴 예시는 다음과 같습니다:\n\nsql\nSELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS business_day,\n count(*)\nFROM orders\nGROUP BY 1\nORDER BY 1;\n\n\n### 불신을 막는 레이블\n\n숫자가 튀고 아무도 이유를 설명하지 못하면 사람들은 차트를 신뢰하지 않습니다. UI에 사용한 규칙을 그대로 라벨로 표시하세요: “Daily orders (America/New_York)” 또는 “Hourly events (UTC)”. 동일한 규칙을 내보내기와 API에도 사용하세요.\n\n## 보고 및 API를 위한 간단한 규칙 집합\n\n값을 저장할 때 그 값이 실제 순간을 묘사하는지, 아니면 그대로 유지해야 하는 로컬 시각인지 결정하세요. 이 둘을 섞어 쓰면 대시보드와 API가 서로 다르게 됩니다.\n\n예측 가능한 보고를 위한 규칙 집합:\n\n- 실제 이벤트는 TIMESTAMPTZ로 저장하고 UTC를 진실의 출처로 삼으세요.\n- “청구일” 같은 비즈니스 개념은 DATE(또는 진정한 벽시계 시간이 필요하면 로컬 시간 필드)로 따로 저장하세요.\n- API에서는 ISO 8601로 타임스탬프를 반환하고 일관되게 하세요: 항상 오프셋을 포함하거나(예: +02:00) 항상 UTC의 Z를 사용하세요.\n- 변환은 엣지(UI 및 보고 계층)에서 수행하세요. 데이터베이스 로직과 백그라운드 잡 안에서 왔다 갔다 변환하지 마세요.\n\n이 방식이 유효한 이유: 대시보드는 범위를 버킷하고 비교합니다. 인스턴트(TIMESTAMPTZ)를 저장하면 PostgreSQL은 DST 전환 시에도 이벤트를 안정적으로 정렬하고 필터링할 수 있습니다. 그런 다음 어떻게 표시하거나 그룹화할지 선택하면 됩니다. 로컬 시각(TIMESTAMP)만 저장하면 PostgreSQL은 그 의미를 알 수 없기 때문에 세션 시간대가 바뀔 때 그룹화 결과가 달라질 수 있습니다.\n\n“로컬 비즈니스 날짜”는 인스턴트가 아니므로 분리하세요. “2026-03-08에 배달”은 날짜의 결정이지 순간이 아닙니다. 이를 타임스탬프에 억지로 넣으면 DST로 인해 로컬 시간이 빠지거나 중복되어 나중에 간극이나 스파이크로 나타납니다.\n\n## 단계별: 각 시간 값에 적절한 타입 고르기\n\nTIMESTAMPTZ와 TIMESTAMP 사이 선택은 한 질문에서 시작합니다: 이 값이 실제로 일어난 순간을 설명하는가, 아니면 정확히 적힌 대로 유지되어야 하는 로컬 시간인가?\n\n### 1) 실제 이벤트와 예약된 로컬 시간을 분리하세요\n\n칼럼 목록을 빠르게 정리하세요.\n\n실제 이벤트(클릭, 결제, 로그인, 배송, 센서 측정, 지원 메시지)는 보통 TIMESTAMPTZ로 저장해야 합니다. 서로 다른 시간대에서 보더라도 하나의 명확한 순간을 원하기 때문입니다.\n\n예약된 로컬 시간은 다릅니다: “매장 오픈 09:00”, “픽업 창구 16:00–18:00”, “청구는 매월 1일 현지 시간 10:00” 등은 TIMESTAMP와 별도의 시간대 필드를 함께 사용하는 것이 더 낫습니다. 의도가 특정 장소의 벽시계 시간에 묶여 있기 때문입니다.\n\n### 2) 표준을 정하고 문서화하세요\n\n대부분 제품에 좋은 기본값은: 이벤트 시간은 UTC로 저장하고, 사용자 시간대로 표시하세요. 이를 스키마 주석, API 문서, 대시보드 설명 등 사람들이 실제로 읽는 곳에 문서화하세요. 또한 “비즈니스 데이”의 정의(UTC 데이인지, 비즈니스 존 데이인지, 뷰어 로컬 데이인지)를 명확히 하세요. 이 선택이 일별 보고를 좌우합니다.\n\n현장에서 작동하는 짧은 체크리스트:\n\n- 각 시간 칼럼을 “event instant” 또는 “local schedule”로 태그하세요.\n- 이벤트 인스턴트는 기본적으로 UTC에 저장된 TIMESTAMPTZ로 하세요.\n- 스키마 변경 시에는 신중히 백필(backfill)하고 샘플 행을 손으로 검증하세요.\n- API 포맷을 표준화하세요(인스턴트에는 항상 Z 또는 오프셋 포함).\n- ETL, BI 커넥터, 백그라운드 워커에서 세션 시간대를 명시적으로 설정하세요.\n\n“변환하고 백필” 작업에 주의하세요. 컬럼 타입을 변경하면 이전 값이 다른 세션 시간대 하에서 해석되었을 경우 의미가 조용히 바뀔 수 있습니다.\n\n## 하루 차이 및 DST 버그를 일으키는 흔한 실수\n\n대부분의 시간 버그는 “PostgreSQL의 이상한 동작”이 아닙니다. 보기 좋은 값을 잘못된 의미로 저장해 여러 계층이 누락된 컨텍스트를 추측하게 만들었기 때문입니다.\n\n### 실수 1: 벽시계 시간을 절대 시간인 양 저장하기\n\n흔한 함정은 지역 벽시계 시간(예: 베를린의 2026-03-29 09:00)을 TIMESTAMPTZ로 저장하는 것입니다. PostgreSQL은 이를 순간으로 처리하고 현재 세션 시간대에 따라 변환합니다. 만약 의도는 “항상 현지 시간 기준 오전 9시”였다면 의미를 잃게 됩니다. 다른 세션 시간대에서 같은 행을 보면 표시되는 시간이 이동합니다.\n\n약속(appointments)은 로컬 시간을 TIMESTAMP로 저장하고 별도의 시간대(또는 위치) 필드를 함께 저장하세요. 일어난 사건은 TIMESTAMPTZ로 저장하세요.\n\n### 실수 2: 환경마다 다른 가정\n\n로컬 노트북, 스테이징, 프로덕션이 동일한 시간대를 쓰지 않을 수 있습니다. 한 환경은 UTC로, 다른 환경은 로컬 시간으로 동작하면 “일별 그룹화” 보고서가 달라집니다. 데이터는 바뀌지 않았지만 세션 설정이 바뀐 것입니다.\n\n### 실수 3: 시간 함수의 보장 사항을 모른 채 사용\n\nnow()와 current_timestamp는 트랜잭션 내에서 안정적입니다. clock_timestamp()는 호출할 때마다 변합니다. 한 트랜잭션에서 여러 시점에 타임스탬프를 생성하면서 이 함수들을 섞어 쓰면 정렬과 기간이 이상해 보일 수 있습니다.\n\n### 실수 4: 두 번 변환하거나 변환을 전혀 안 함\n\n자주 발생하는 API 버그: 앱이 로컬 시간을 UTC로 변환해 놓고, 이를 나이브 문자열로 전송하면 DB 세션이 입력을 로컬 시간으로 간주해 다시 변환하는 경우가 있습니다. 반대 경우도 발생합니다: 앱이 로컬 시간을 보냈는데 Z(UTC)를 붙여 전송해 렌더링 시 이동됩니다.\n\n### 실수 5: 의도한 시간대를 밝히지 않고 날짜로 그룹화\n\n“일별 합계”는 어느 날 경계를 말하는지에 따라 달라집니다. TIMESTAMPTZ에서 date(created_at)로 그룹화하면 결과는 세션 시간대를 따릅니다. 늦은 밤 이벤트가 이전 또는 다음 날로 옮겨질 수 있습니다.\n\n대시보드나 API를 배포하기 전에 기본을 점검하세요: 차트마다 하나의 보고 시간대를 선택해 일관되게 적용하고, API 페이로드에 오프셋이나 Z를 포함시키고, 스테이징과 프로덕션의 시간대 정책을 일치시키며, 그룹화 시 어떤 시간대를 의미하는지 명시하세요.\n\n## 대시보드나 API 배포 전의 빠른 점검표\n\n시간 버그는 보통 하나의 잘못된 쿼리에서 오는 것이 아닙니다. 저장, 보고, API 각각이 약간씩 다른 가정을 하기 때문에 발생합니다.\n\n간단한 출고 전 체크리스트:\n\n- 실제 이벤트(가입, 결제, 센서 신호)는 인스턴트로 TIMESTAMPTZ에 저장하세요.\n- 비즈니스 로컬 개념(청구일, 보고 날짜)은 DATE나 TIME으로 저장하세요. 나중에 변환하려고 타임스탬프에 넣지 마세요.\n- 스케줄 잡과 리포트 러너에서는 세션 시간대를 의도적으로 설정하세요.\n- API 응답에는 오프셋 또는 Z를 포함하고, 클라이언트가 이를 시간대 인지로 파싱하는지 확인하세요.\n- 대상 시간대의 DST 전환 주를 최소 하나 테스트하세요.\n\n빠른 종단간 검증: 하나의 알려진 엣지 케이스 이벤트(예: DST 관찰 지역의 2026-03-08 01:30)를 골라 저장, 쿼리 출력, API JSON, 최종 차트 라벨까지 따라가 보세요. 차트가 올바른 날짜를 보여주지만 툴팁에 잘못된 시간이 나오거나 그 반대라면 변환 불일치가 있는 것입니다.\n\n## 예시: 같은 날짜 수치로 두 팀이 다투는 이유\n\n뉴욕의 지원팀과 베를린의 재무팀이 같은 대시보드를 봅니다. DB 서버는 UTC로 동작합니다. 모두 각자 숫자가 맞다고 주장하지만 “어제”가 누구에게는 다르게 보입니다.\n\n사건 예시는 다음과 같습니다: 고객 티켓이 뉴욕 시간으로 3월 10일 23:30에 생성되었습니다. 이는 UTC로는 3월 11일 04:30, 베를린으로는 05:30입니다. 한 번의 실제 순간, 세 가지 달력 날짜.\n\n만약 생성 시간이 TIMESTAMP(시간대 없음)로 저장되어 앱이 이를 “로컬”로 가정하면 역사가 조용히 다시 쓰여질 수 있습니다. 뉴욕은 2026-03-10 23:30을 뉴욕 시간으로 해석하지만 베를린은 같은 저장 값을 베를린 시간으로 해석할 수 있습니다. 동일한 행이 서로 다른 날짜에 놓이게 됩니다.\n\nTIMESTAMPTZ로 저장하면 PostgreSQL은 순간을 일관되게 저장하고 조회나 포맷 시에만 변환합니다. 이 때문에 TIMESTAMPTZ와 TIMESTAMP의 차이가 보고에서 “하루”의 의미를 바꿉니다.\n\n해결 방법은 두 가지 개념을 분리하는 것입니다: 사건이 발생한 순간(event instant), 그리고 사용하고자 하는 보고 날짜(reporting date).\n\n실용적 패턴:\n\n1) 이벤트 시간을 TIMESTAMPTZ로 저장하세요.\n2) 보고 규칙을 정하세요: 뷰어 로컬(개인 대시보드)인지, 아니면 하나의 비즈니스 존(회사 전체 재무)인지.\n3) 쿼리 시 해당 규칙으로 인스턴트를 선택한 존으로 변환한 뒤 날짜를 계산하세요.\n\n## 다음 단계: 스택 전반에 걸쳐 시간 처리 표준화\n\n시간 처리 규칙이 문서화되어 있지 않으면 새 리포트마다 추측 게임이 됩니다. 데이터베이스, API, 대시보드 전반에서 지루하고 예측 가능한 시간 동작으로 목표를 세우세요.\n\n짧은 “시간 계약(time contract)”을 작성해 세 가지 질문에 답하세요:\n\n- 이벤트 시간 표준: 특별한 이유가 없다면 이벤트 인스턴트를 TIMESTAMPTZ(보통 UTC)로 저장하세요.\n- 비즈니스 시간대: 보고에 사용할 하나의 존을 선택하고 일/주/월을 정의할 때 일관되게 사용하세요.\n- API 포맷: 타임스탬프는 항상 오프셋을 포함한 ISO 8601(또는 Z)로 보내고, 각 필드가 “instant”인지 “local wall time”인지 문서화하세요.\n\nDST 시작과 종료 주변에 작은 테스트를 추가하세요. 비용이 큰 버그를 초기에 잡아냅니다. 예를 들어, 특정 비즈니스 존에 대해 “일별 합계” 쿼리가 DST 변경 전후에도 안정적인지, 또한 2026-11-01T01:30:00-04:00과 2026-11-01T01:30:00-05:00이 서로 다른 순간으로 처리되는지 검증하세요.\n\n마이그레이션은 신중히 계획하세요. 타입과 가정을 제자리에서 바꾸면 차트의 역사가 조용히 다시 쓰일 수 있습니다. 더 안전한 방법은 새 칼럼을 추가하는 것입니다(예: created_at_utc TIMESTAMPTZ), 검토된 변환으로 백필하고 읽기를 새 칼럼으로 전환한 뒤, 잠시 동안 옛 보고와 새 보고를 병행해 일별 수치의 변화를 명확히 확인한 다음 옛 칼럼을 제거하세요.\n\n만약 데이터 모델, API, 스크린 전반에서 이 “시간 계약”을 적용할 중앙 장소가 필요하다면 통합된 빌드 설정이 도움이 됩니다. AppMaster (appmaster.io)는 하나의 프로젝트에서 백엔드, 웹 앱, API를 생성해 타임스탬프 저장과 표시 규칙을 앱 성장에 맞춰 일관되게 유지하기 쉽도록 합니다.
자주 묻는 질문
Use TIMESTAMPTZ for anything that happened at a real moment (signups, payments, logins, messages, sensor pings). It stores one unambiguous instant and can be safely sorted, filtered, and compared across systems. Use plain TIMESTAMP only when the value is meant to be a wall-clock time that should stay exactly as written, usually paired with a separate time zone or location field.
TIMESTAMPTZ represents a real instant in time; PostgreSQL normalizes it internally and then displays it in your session time zone. TIMESTAMP is just a date and clock time with no zone attached, so PostgreSQL won’t shift it automatically. The key difference is meaning: instant versus local wall time.
Because the session time zone controls how TIMESTAMPTZ is formatted on output and how some inputs are interpreted. Two tools can query the same row and show different clock times if one session is set to UTC and another to America/Los_Angeles. For reports and APIs, set the session time zone explicitly so results don’t depend on hidden defaults.
Because “a day” depends on a time zone boundary. If one dashboard groups by viewer-local time while another groups by UTC (or a business zone), late-night events can fall on different dates and change daily totals. Fix it by picking one grouping rule per chart (UTC or a specific business zone) and using it consistently in SQL, BI, and exports.
DST creates missing or duplicated local hours, which can produce gaps or double-counted buckets when grouping by local time. If your data represents real moments, store it as TIMESTAMPTZ and choose a clear chart time zone for bucketing. Also test the DST transition week for your target zones to catch surprises early.
No, PostgreSQL does not preserve the original time zone label with TIMESTAMPTZ; it stores the instant. When you query it, PostgreSQL displays it in the session time zone, which may differ from the user’s original zone. If you need “show it in the customer’s time zone,” store that zone separately in another column.
Return ISO 8601 timestamps that include an offset, and be consistent. A simple default is to always return UTC with Z for event instants, then let clients convert for display. Avoid sending “naive” strings like 2026-03-10 23:30:00 because clients will guess the zone differently.
Convert at the edges: store event instants as TIMESTAMPTZ, then convert to the desired zone when you display or bucket for reporting. Avoid converting back and forth inside triggers, background jobs, and ETL unless you have a clear contract. Most reporting problems come from double conversion or from mixing naive and time-zone-aware values.
Use DATE for business concepts that are truly dates, like “billing day,” “reporting date,” or “delivery date.” Use TIME (or TIMESTAMP plus a separate time zone) for schedules like “opens at 09:00 local time.” Don’t force these into TIMESTAMPTZ unless you really mean a single instant, because DST and zone changes can shift the intended meaning.
First, decide whether it’s an instant (TIMESTAMPTZ) or a local wall time (TIMESTAMP plus zone), then add a new column instead of rewriting in place. Backfill with a reviewed conversion under a known session time zone, and validate sample rows around midnight and DST boundaries. Run old and new reports side by side briefly so any shifts in totals are obvious before you remove the old column.


