Jan 21, 2026·7 min read

TIMESTAMPTZ vs TIMESTAMP: PostgreSQL dashboards and APIs

TIMESTAMPTZ vs TIMESTAMP in PostgreSQL: how the type you pick impacts dashboards, API responses, time zone conversions, and daylight saving time bugs.

TIMESTAMPTZ vs TIMESTAMP: PostgreSQL dashboards and APIs

The real problem: one time, many interpretations

An event happens once, but it gets reported in a dozen different ways. The database stores a value, an API serializes it, a dashboard groups it, and each person views it in their own time zone. If any layer makes a different assumption, the same row can look like two different moments.

That’s why TIMESTAMPTZ vs TIMESTAMP isn’t just a data type preference. It decides whether a stored value represents a specific instant in time, or a wall-clock time that only makes sense in a particular place.

This is what usually breaks first: a sales dashboard shows different daily totals in New York and Berlin. An hourly chart has a missing hour or a duplicated hour during daylight saving time (DST) changes. An audit log looks out of order because two systems “agree” on the date but not the actual instant.

A simple model keeps you out of trouble:

  • Storage: what you save in PostgreSQL and what it represents.
  • Display: how you format it in a UI, export, or report.
  • User locale: the viewer’s time zone and calendar rules, including DST.

Mix those up and you get quiet reporting bugs. A support team exports “tickets created yesterday” from a dashboard, then compares it to an API report. Both seem reasonable, but one used the viewer’s local midnight boundary while the other used UTC.

The goal is simple: for every time value, make two clear choices. Decide what you store, and decide what you show. That same clarity has to carry through your data model, API responses, and dashboards so everyone sees the same timeline.

What TIMESTAMP and TIMESTAMPTZ actually mean

In PostgreSQL, the names are misleading. They look like they describe what is stored, but they mostly describe how PostgreSQL interprets input and formats output.

TIMESTAMP (aka timestamp without time zone) is just a calendar date and clock time, like 2026-01-29 09:00:00. No time zone is attached. PostgreSQL won’t convert it for you. Two people in different time zones can read the same TIMESTAMP and assume different real-world moments.

TIMESTAMPTZ (aka timestamp with time zone) represents a real point in time. Think of it as an instant. PostgreSQL normalizes it internally (effectively to UTC), then displays it in whatever time zone your session is using.

The behavior behind most surprises is:

  • On input: PostgreSQL converts TIMESTAMPTZ values to a single comparable instant.
  • On output: PostgreSQL formats that instant using the current session time zone.
  • For TIMESTAMP: no automatic conversion happens on input or output.

A small example shows the difference. Suppose your app receives 2026-03-08 02:30 from a user. If you insert it into a TIMESTAMP column, PostgreSQL stores exactly that wall-clock value. If that local time doesn’t exist because of a DST jump, you might not notice until reporting breaks.

If you insert into TIMESTAMPTZ, PostgreSQL needs a time zone to interpret the value. If you provide 2026-03-08 02:30 America/New_York, PostgreSQL converts it to an instant (or throws an error depending on rules and the exact value). Later, a dashboard in London will show a different local clock time, but it’s the same instant.

One common misconception: people see “with time zone” and expect PostgreSQL to store the original time zone label. It doesn’t. PostgreSQL stores the moment, not the label. If you need the user’s original time zone for display (for example, “show it in the customer’s local time”), store the zone separately as a text field.

Session time zone: the hidden setting behind many surprises

PostgreSQL has a setting that quietly changes what you see: the session time zone. Two people can run the same query on the same data and get different clock times because their sessions use different time zones.

This mostly affects TIMESTAMPTZ. PostgreSQL stores an absolute moment, then displays it in the session time zone. With TIMESTAMP (no time zone), PostgreSQL treats the value as plain calendar time. It doesn’t shift it for display, but the session time zone can still hurt you when you convert it to TIMESTAMPTZ or compare it with time-zone-aware values.

Session time zones often get set without you noticing: application startup config, driver parameters, connection pools reusing old sessions, BI tools with their own defaults, ETL jobs inheriting server locale settings, or manual SQL consoles using your laptop’s preferences.

Here’s how teams end up arguing. Suppose an event is stored as 2026-03-08 01:30:00+00 in a TIMESTAMPTZ column. A dashboard session in America/Los_Angeles will display it as the prior evening local time, while an API session in UTC shows a different clock time. If a chart groups by day using the session-local day, you can get different daily totals.

-- Make your output consistent for a reporting job
SET TIME ZONE 'UTC';

SELECT created_at, date_trunc('day', created_at) AS day_bucket
FROM events;

For anything that produces reports or API responses, make the time zone explicit. Set it on connect (or run SET TIME ZONE first), pick one standard for machine outputs (often UTC), and for “local business time” reports set the business zone inside the job, not on someone’s laptop. If you use pooled connections, reset session settings when a connection is checked out.

How dashboards break: grouping, buckets, and DST gaps

Dashboards look simple: count orders per day, show signups per hour, compare week over week. Problems start when the database stores one “moment” but the chart turns it into many different “days,” depending on who is looking.

If you group by day using a user’s local time zone, two people can see different dates for the same event. An order placed at 23:30 in Los Angeles is already “tomorrow” in Berlin. And if your SQL groups by DATE(created_at) on a plain TIMESTAMP, you’re not grouping by a real moment. You’re grouping by a wall-clock reading with no time zone attached.

Hourly charts get messier around DST. In spring, one local hour never happens, so charts can show a gap. In fall, one local hour happens twice, so you can get a spike or double buckets if your query and dashboard disagree about which 01:30 you mean.

A practical question helps: are you charting real moments (safe to convert), or a local schedule time (must not be converted)? Dashboards almost always want real moments.

When to group by UTC vs a business time zone

Pick one grouping rule and apply it everywhere (SQL, API, BI tool), otherwise totals drift.

Group by UTC when you want a global, consistent series (system health, API traffic, global signups). Group by a business time zone when “the day” has a legal or operational meaning (store day, support SLAs, finance close). Group by the viewer’s time zone only when personalization matters more than comparability (personal activity feeds).

Here’s the pattern for consistent “business day” grouping:

SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS business_day,
       count(*)
FROM orders
GROUP BY 1
ORDER BY 1;

Labels that prevent mistrust

People stop trusting charts when numbers jump and no one can explain why. Label the rule прямо in the UI: “Daily orders (America/New_York)” or “Hourly events (UTC)”. Use the same rule in exports and APIs.

A simple rule set for reporting and APIs

Ship a clear timestamp API
Generate APIs that return ISO 8601 timestamps with offsets so clients don’t guess.
Create API

Decide whether you’re storing an instant in time or a local clock reading. Mixing those two is where dashboards and APIs start to disagree.

A rule set that keeps reporting predictable:

  • Store real-world events as instants using TIMESTAMPTZ, and treat UTC as the source of truth.
  • Store business concepts like “billing day” separately as a DATE (or a local-time field if you truly need wall clock time).
  • In APIs, return timestamps in ISO 8601 and be consistent: always include an offset (like +02:00) or always use Z for UTC.
  • Convert at the edges (UI and reporting layer). Avoid converting back and forth inside database logic and background jobs.

Why this holds up: dashboards bucket and compare ranges. If you store instants (TIMESTAMPTZ), PostgreSQL can reliably order and filter events even when DST shifts. Then you choose how to display or group them. If you store a local clock time (TIMESTAMP) without a time zone, PostgreSQL can’t know what it means, so grouping can change when the session time zone changes.

Keep “local business dates” separate because they are not instants. “Deliver on 2026-03-08” is a date decision, not a moment. If you force it into a timestamp, DST days can create missing or duplicated local hours, which later show up as gaps or spikes.

Step by step: choosing the right type for each time value

Prototype a reporting-safe app
Turn your time contract into a working app fast, then adjust without technical debt.
Prototype Now

Choosing between TIMESTAMPTZ vs TIMESTAMP starts with one question: does this value describe a real moment that happened, or a local time you want to keep exactly as written?

1) Separate real events from scheduled local times

Make a quick inventory of your columns.

Real events (clicks, payments, logins, shipments, sensor readings, support messages) should usually be stored as TIMESTAMPTZ. You want one unambiguous instant, even if people view it from different time zones.

Scheduled local times are different: “Store opens at 09:00”, “Pickup window is 16:00 to 18:00”, “Billing runs on the 1st at 10:00 local time”. These are often better as TIMESTAMP plus a separate time zone field, because the intent is tied to a location’s wall clock.

2) Pick a standard and write it down

For most products, a good default is: store event times in UTC, present them in the user’s time zone. Document it in places people actually read: schema notes, API docs, and dashboard descriptions. Also define what “business day” means (UTC day, business zone day, or viewer-local day), because that choice drives daily reporting.

A short checklist that works in practice:

  • Tag each time column as “event instant” or “local schedule”.
  • Default event instants to TIMESTAMPTZ stored in UTC.
  • When changing schemas, backfill carefully and validate sample rows by hand.
  • Standardize API formats (always include Z or an offset for instants).
  • Set the session time zone explicitly in ETL jobs, BI connectors, and background workers.

Be careful with “convert and backfill” work. Changing a column type can silently change meaning if old values were interpreted under a different session time zone.

Common mistakes that cause off-by-one-day and DST bugs

Most time bugs aren’t “PostgreSQL being weird.” They come from storing the right-looking value with the wrong meaning, then letting different layers guess the missing context.

Mistake 1: Saving a wall-clock time as if it were absolute

A common trap is storing local wall-clock times (like “2026-03-29 09:00” in Berlin) in a TIMESTAMPTZ. PostgreSQL treats it as an instant and converts it based on the current session time zone. If the intended meaning was “always 9 AM local time,” you just lost it. Viewing the same row under a different session time zone shifts the displayed hour.

For appointments, store the local time as TIMESTAMP plus a separate time zone (or location) field. For events that happened at a moment (payments, logins), store the instant as TIMESTAMPTZ.

Mistake 2: Different environments, different assumptions

Your laptop, staging, and production might not share the same time zone. One environment runs in UTC, another runs in local time, and “group by day” reports start disagreeing. The data didn’t change, the session setting did.

Mistake 3: Using time functions without knowing what they promise

now() and current_timestamp are stable within a transaction. clock_timestamp() changes every call. If you generate timestamps at multiple points in one transaction and mix these functions, ordering and durations can look strange.

Mistake 4: Converting twice (or zero times)

A frequent API bug: the app converts a local time to UTC, sends it as a naive string, then the database session converts again because it assumes the input was local time. The opposite also happens: the app sends a local time but labels it with Z (UTC), shifting it when rendered.

Mistake 5: Grouping by date without stating the intended time zone

“Daily totals” depends on which day boundary you mean. If you group with date(created_at) on a TIMESTAMPTZ, the result follows the session time zone. Late-night events can move into the previous or next day.

Before shipping a dashboard or API, sanity-check the basics: pick one reporting time zone per chart and apply it consistently, include offsets (or Z) in API payloads, keep staging and production aligned on time zone policy, and be explicit about which time zone you mean when grouping.

Quick checks before you ship a dashboard or API

Make time handling consistent
Build your backend and API from one place so timestamp rules stay consistent.
Try AppMaster

Time bugs rarely come from one bad query. They happen because storage, reporting, and the API each make a slightly different assumption.

Use a short pre-ship checklist:

  • For real-world events (signups, payments, sensor pings), store the instant as TIMESTAMPTZ.
  • For business-local concepts (billing day, reporting date), store a DATE or TIME, not a timestamp you plan to “convert later.”
  • In scheduled jobs and report runners, set the session time zone on purpose.
  • In API responses, include an offset or Z, and confirm the client parses it as time-zone-aware.
  • Test the DST transition week for at least one target time zone.

A fast end-to-end validation: pick one known edge-case event (for example, 2026-03-08 01:30 in a DST-observing zone) and follow it through storage, query output, API JSON, and the final chart label. If the chart shows the right day but the tooltip shows the wrong hour (or vice versa), you’ve got a conversion mismatch.

Example: why two teams disagree on the same day’s numbers

Launch with secure access
Start with built-in authentication and focus on your data and reporting rules.
Add Auth

A support team in New York and a finance team in Berlin look at the same dashboard. The database server runs on UTC. Everyone insists their numbers are right, but “yesterday” is different depending on who you ask.

Here’s the event: a customer ticket is created at 23:30 in New York on March 10. That’s 04:30 UTC on March 11, and 05:30 in Berlin. One real moment, three different calendar dates.

If the ticket’s creation time is stored as TIMESTAMP (no time zone) and your app assumes it’s “local,” you can quietly rewrite history. New York might treat 2026-03-10 23:30 as New York time, while Berlin interprets that same stored value as Berlin time. The same row lands on different days for different viewers.

If it’s stored as TIMESTAMPTZ, PostgreSQL stores the instant consistently and only converts it when someone views or formats it. This is why TIMESTAMPTZ vs TIMESTAMP changes what “a day” means in reports.

The fix is to separate two ideas: the instant the event happened, and the reporting date you want to use.

A practical pattern:

  1. Store the event time as TIMESTAMPTZ.
  2. Decide the reporting rule: viewer-local (personal dashboards) or one business time zone (company-wide finance).
  3. Compute the reporting date at query time using that rule: convert the instant to the chosen zone, then take the date.

Next steps: standardize time handling across your stack

If time handling isn’t written down, every new report becomes a guessing game. Aim for time behavior that’s boring and predictable across the database, APIs, and dashboards.

Write a short “time contract” that answers three questions:

  • Event time standard: store event instants as TIMESTAMPTZ (typically in UTC) unless you have a strong reason not to.
  • Business time zone: pick one zone for reporting, and use it consistently when you define “day,” “week,” and “month.”
  • API format: always send timestamps with an offset (ISO 8601 with Z or +/-HH:MM) and document whether fields mean “instant” or “local wall time.”

Add small tests around DST start and DST end. They catch expensive bugs early. For example, validate that a “daily total” query is stable for a fixed business zone across a DST change, and that API inputs like 2026-11-01T01:30:00-04:00 and 2026-11-01T01:30:00-05:00 are treated as two different instants.

Plan migrations carefully. Changing types and assumptions in place can silently rewrite history in charts. A safer approach is to add a new column (for example, created_at_utc TIMESTAMPTZ), backfill it with a reviewed conversion, update reads to use the new column, then update writes. Keep old and new reports side by side briefly so shifts in daily numbers are obvious.

If you want one place to enforce this “time contract” across data models, APIs, and screens, a unified build setup helps. AppMaster (appmaster.io) generates backend, web app, and APIs from a single project, which makes it easier to keep timestamp storage and display rules consistent as your app grows.

FAQ

When should I use TIMESTAMPTZ instead of TIMESTAMP?

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.

What’s the real difference between TIMESTAMP and TIMESTAMPTZ in PostgreSQL?

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.

Why do I see different times for the same row depending on who runs the query?

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.

Why do daily totals change between New York and Berlin?

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.

How do I avoid DST bugs like missing or duplicated hours in hourly charts?

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.

Does TIMESTAMPTZ store the user’s time zone?

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.

What should my API return for timestamps to avoid confusion?

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.

Where should time zone conversion happen: database, API, or UI?

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.

How should I store business days and schedules like “run at 10:00 local time”?

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.

How can I migrate from TIMESTAMP to TIMESTAMPTZ without breaking reports?

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.

Easy to start
Create something amazing

Experiment with AppMaster with free plan.
When you will be ready you can choose the proper subscription.

Get Started