Easily misused data types: why a 23:30 order lands on the next day and totals are off by 1 VND Basic
Estimated reading time: 8 min read
The problem
Section titled “The problem”A shop selling in Vietnam and Japan shares one database. Order 1, worth 450,000 VND, is created at 23:30 on Oct 22, Vietnam time. A revenue report rerun by the team in Tokyo puts this order on Oct 23.
The same week, tax reconciliation is off by 1 VND: ceil(450000 * 1.1) computed in float8 gives 495,001 instead of 495,000.
The article Numbers, dates, strings lists the mistakes on the Node side. This one goes into PostgreSQL 17’s mechanics: which value is actually stored, and what decides how it is displayed?
What to choose (summary first)
Section titled “What to choose (summary first)”| Situation | Choose | Condition |
|---|---|---|
| Event time: order created, payment | timestamptz |
Daily reports state the time zone explicitly with AT TIME ZONE |
| “Wall clock” time: opens at 08:00 | timestamp or time |
No comparison of instants across countries |
| VND, JPY money (no minor unit) | bigint |
Tax and FX computed in numeric, then rounded deliberately |
| Money with fractions, exchange rates | numeric(p, s) |
Accept slower math than integers |
| Measurements, coordinates | float8 |
No = comparisons, never for money |
| Strings | text (+ CHECK if needed) |
Avoid char(n) |
Core concepts
Section titled “Core concepts”| Type | Stores | Displayed as |
|---|---|---|
timestamp |
A “bare” date and time, no time zone | Exactly as stored |
timestamptz |
An absolute instant, internally UTC | Per the session’s TimeZone |
numeric(p, s) |
Exact decimal (p digits, s after the point) |
Verbatim |
float8 / real |
8 / 4 byte binary floating point | Shortest exact-enough form |
text, varchar(n), char(n) |
varlena string | char(n) pads with spaces to n |
timestamptzdoes not store a time zone: the input zone is only used to convert to UTC, then discarded.timestampsilently drops the input offset:TIMESTAMP '2026-10-22 23:30:00+07'gives2026-10-22 23:30:00.
How it works
Section titled “How it works”timestamptz: stored as UTC, displayed per session
Section titled “timestamptz: stored as UTC, displayed per session”The question: order 1 is stored once, so why do two readers see two different days?
23:30+07:00The app sends order 1's Date with an offset: 2026-10-22T23:30+07:00.
| Step | Session sees (the stored value is always 16:30 UTC) |
|---|---|
3. TimeZone=UTC |
2026-10-22 16:30:00+00 |
4. TimeZone=Asia/Tokyo |
2026-10-23 01:30:00+09, ::date = 23 |
5. AT TIME ZONE 'Asia/Ho_Chi_Minh' |
2026-10-22 23:30:00 in every session |
created_at::date cuts the day by the TimeZone of the session running the report, not by where the order was created.
AT TIME ZONE flips direction with the input type
Section titled “AT TIME ZONE flips direction with the input type”Loading diagram…
timestamptz AT TIME ZONE z→timestamp: “what did the clock inzshow at that instant?”.timestamp AT TIME ZONE z→timestamptz: “this is local time inz, convert it to an absolute instant”.
Real output (session TimeZone=UTC):
SELECT TIMESTAMP '2026-10-22 23:30' AT TIME ZONE 'Asia/Ho_Chi_Minh' AS a, TIMESTAMPTZ '2026-10-22 23:30+07' AT TIME ZONE 'Asia/Ho_Chi_Minh' AS b;-- a | b-- ------------------------+----------------------- 2026-10-22 16:30:00+00 | 2026-10-22 23:30:00Revenue per Vietnam day, run from a Tokyo session:
SET TimeZone = 'Asia/Tokyo';SELECT created_at::date AS day, sum(total_vnd) FROM orders GROUP BY 1;-- day | sum-- 2026-10-23 | 570000 -- order 1 (Oct 22 VN time) is lumped into Oct 23
SELECT date_trunc('day', created_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS vn_day, sum(total_vnd) FROM orders GROUP BY 1 ORDER BY 1;-- vn_day | sum-- 2026-10-22 00:00:00 | 450000-- 2026-10-23 00:00:00 | 120000To filter one VN day, compare a range on the column itself so a B-tree index can be used: created_at >= TIMESTAMP '2026-10-22 00:00' AT TIME ZONE 'Asia/Ho_Chi_Minh' and < the same bound for day 23.
numeric and float8
Section titled “numeric and float8”float8 stores numbers in base 2, so 0.1 is only an approximation. The docs classify float as inexact, recommend numeric for money, and describe numeric arithmetic as “very slow” compared with integers and floats.
SELECT 0.1::float8 + 0.2::float8 AS f8, 0.1::numeric + 0.2 AS num, ceil(450000 * 1.1::float8) AS f8_ceil, ceil(450000 * 1.1::numeric) AS num_ceil;-- f8 | num | f8_ceil | num_ceil-- 0.30000000000000004 | 0.3 | 495001 | 495000
SELECT round(2.5::float8) AS f8_r, round(2.5::numeric) AS num_r;-- f8_r | num_r-- 2 | 3450000 * 1.1in float gives495000.00000000006;ceilpushes it up by 1 VND.- Different rounding rules:
numericrounds half away from zero, whilefloat8(on most machines) rounds half to even. realis only precise to about 6 digits.
text, varchar(n), char(n)
Section titled “text, varchar(n), char(n)”The docs say there is no performance difference among the three, apart from the extra storage of char(n) and a few CPU cycles for length checks; char(n) is usually the slowest.
CREATE TABLE t (c char(5), v varchar(5), x text CHECK (char_length(x) <= 5));INSERT INTO t VALUES ('ab', 'ab', 'ab');SELECT '[' || c || ']' AS c_concat, octet_length(c), length(c), c = 'ab ' AS eq FROM t;-- c_concat | octet_length | length | eq-- [ab] | 5 | 2 | tINSERT INTO t (v) VALUES ('abcdef'); -- ERROR: value too long for type character varying(5)INSERT INTO t (x) VALUES ('abcdef'); -- ERROR: ... violates check constraint "t_x_check"SELECT 'abcdef'::varchar(5); -- abcde (explicit cast truncates silently)char(n) stores the padding spaces (5 bytes) but ignores trailing spaces when comparing and when converting to text. A CHECK limits length like varchar(n); changing the limit only means replacing the constraint.
Operational details
Section titled “Operational details”now()=transaction_timestamp(): the transaction start time. Demo: afterpg_sleep(1.2)in the same transaction,now()stays at13:15:13.171729whileclock_timestamp()has moved to13:15:14.379239. A long transaction writingnow()records a time earlier than the commit.TimeZonecan be set per server, role, database or session. The demo container defaults toUTC.- Strings max out around 1 GB; ≤ 126 bytes costs a 1-byte header, long strings are compressed or moved to TOAST (see storage layout).
Real-world example
Section titled “Real-world example”Demo examples/src/data-type-pitfalls/demo.ts (node-postgres 8.23, PostgreSQL 17.11). Row 1 is written by SQL as 23:30; row 2 is sent by Node as new Date('2026-10-22T16:30:00Z'):
TZ=Asia/Ho_Chi_Minh npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.tsTZ=Asia/Tokyo npx --prefix examples ts-node examples/src/data-type-pitfalls/demo.tsprocess TZ=Asia/Ho_Chi_Minh, session TimeZone=UTCtimestamp -> 2026-10-22T16:30:00.000Ztimestamptz -> 2026-10-22T16:30:00.000Znumeric -> string "450000"bigint -> string "450000"count(*) -> string "1"Date -> timestamp stored : 2026-10-22 23:30:00Date -> timestamptz stored : 2026-10-22 16:30:00+00
process TZ=Asia/Tokyo, session TimeZone=UTCtimestamp -> 2026-10-22T14:30:00.000Ztimestamptz -> 2026-10-22T16:30:00.000Z...Date -> timestamp stored : 2026-10-23 01:30:00timestamptz→Dateis the correct instant under everyTZ;timestampis interpreted in the process’sTZ.numeric,bigintandcount(*)come back as strings by default.- An easy-to-miss exception:
numeric[]becomes an array of floats, so12345678901234567.89comes out as12345678901234568.
Trade-offs
Section titled “Trade-offs”Pros
-
timestamptzgives a single instant that compares correctly across every pod and session. -
numericgives exact results for addition, subtraction and multiplication, with clear rounding rules. -
bigintin the smallest unit: fast, compact 8 bytes, exact sums.
< slot="cons"> >
-
timestamptzdoes not remember the original zone; if you need the customer’s local time, store a time zone name column too. -
numericis slower than integers and floats; the driver returns strings, so the app needs a decimal library. -
bigintforces every division (tax, FX) to commit to a rounding rule.
Cons
Production checklist
Section titled “Production checklist”- Every “when did the event happen” column is
timestamptz; audit existingtimestampcolumns. - Daily reports state the business time zone explicitly with
AT TIME ZONEordate_trunc(..., 'Asia/Ho_Chi_Minh'), never relying on the session’sTimeZone. - Set
TZ=UTCfor every app process so behavior matches across environments. - Money:
bigintin the smallest unit (VND, JPY) ornumeric(p, s), with a currency column; no money columns of typereal/float8. - Decide how the driver parses
int8,numericandnumeric[], and apply it app-wide. - Use
text+CHECK (char_length(...) <= n)orvarchar(n); do not create newchar(n)columns.
Common mistakes
Section titled “Common mistakes”The most common mistake is converting twice:
Applying AT TIME ZONE twice or to the wrong type
Common
SET TimeZone = 'Asia/Tokyo';SELECT created_at::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh' FROM orders;-- 2026-10-23 03:30:00+09 (correct is 01:30+09): ::timestamp takes Tokyo time and treats it as VN timePrefer
SELECT created_at AT TIME ZONE 'Asia/Ho_Chi_Minh' AS vn_time FROM orders;-- 2026-10-22 23:30:00 in every sessionWhy
Check the type with pg_typeof(...) before adding AT TIME ZONE.
The second mistake comes from how length limits are written:
| Misconception | Consequence | What to do |
|---|---|---|
timestamptz stores the original zone |
No way to tell whether the order came from VN or Japan | Store a time zone column if needed |
created_at::date is the day where the sale happened |
The report changes with the session | AT TIME ZONE the business zone |
real is enough for prices |
Wrong from the 7th digit | bigint or numeric |
Self-check
Section titled “Self-check”- Two sessions with different
TimeZoneread the sametimestamptzrow. Is the stored value different? Why does::datediffer? - What type does
x AT TIME ZONE 'Asia/Tokyo'return whenxistimestamp, and whenxistimestamptz? - Why does
ceil(450000 * 1.1::float8)give 495,001 whilenumericgives 495,000?
Summary
Section titled “Summary”References
Section titled “References”- PostgreSQL 17 — Time Zones:
timestamptzstores UTC, displayed perTimeZone. - AT TIME ZONE: the two conversion directions.
- Current Date/Time:
now()andclock_timestamp(). - Numeric Types: precision, speed, rounding rules.
- Character Types: performance, space padding, truncation on cast.
- node-postgres — Data Types: parsing
TIMESTAMPperprocess.env.TZ.