บทความ › การเขียนโปรแกรม

เก็บวันเวลาให้ถูก: เก็บเป็น UTC เก็บ timezone ผู้ใช้ แล้วค่อยแปลงตอนแสดง

เวลาเพี้ยน 7 ชั่วโมง วันเกิดเลื่อน นัดหมายเคลื่อนตอน DST แก้ด้วยกฎสามข้อ ใช้ timestamptz ใน Postgres, zoneinfo ใน Python, Temporal ใน JS และแปลงเป็น พ.ศ. ตอนแสดงเท่านั้น

เริ่มต้น อ่าน 13 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • เวลาที่เกิดขึ้นจริง (instant) เก็บเป็น UTC ใน timestamptz ส่วนวันในปฏิทิน เช่น วันเกิด เก็บเป็น DATE ไม่มีเวลา
  • เก็บชื่อโซนแบบ IANA เช่น Asia/Bangkok ไว้กับผู้ใช้ ไม่ใช่ +07:00 แล้วแปลงเป็นเวลาท้องถิ่นตอนแสดงผลเท่านั้น
  • บวก 1 วัน กับบวก 24 ชั่วโมง ให้ผลต่างกันในประเทศที่มี DST ต้องเลือกให้ตรงกับความหมายของงาน
  • พ.ศ. คือ ค.ศ. + 543 เป็นเรื่องของการแสดงผล ห้ามเก็บลงฐานข้อมูล

บั๊กเวลาเป็นบั๊กที่เงียบที่สุด ระบบทำงานปกติทุกวัน จนลูกค้าต่างประเทศได้แจ้งเตือนผิดชั่วโมง หรือรายงานรายวันนับยอดตอนตีหนึ่งเข้าวันผิด ต้นเหตุเกือบทุกครั้งคือเก็บเวลาแบบไม่บอกโซน หรือแปลงโซนผิดที่ บทความนี้สรุปกฎสามข้อที่ใช้กับทุกระบบ พร้อมโค้ด Postgres, Python และ JavaScript ที่รันผลจริง

ทดสอบบน Windows 11, Python 3.12 + tzdata 2026.5, Node.js 20 + temporal-polyfill 1.0.6, PostgreSQL 18.6 (รันผ่าน PGlite 0.5.11)

instant กับวันในปฏิทิน ไม่ใช่ของเดียวกัน

ก่อนเลือกชนิดข้อมูล ถามก่อนว่าค่านี้คืออะไร

ชนิดความหมายตัวอย่างเก็บเป็น
instantจุดเวลาเดียวกันทั้งโลกสั่งซื้อเมื่อ, login เมื่อ, ส่งข้อความเมื่อtimestamptz (เก็บ UTC)
วันในปฏิทินวันที่ ไม่มีเวลา ไม่ขึ้นกับโซนวันเกิด, วันหยุด, วันครบกำหนดสัญญาdate
เวลานัดในอนาคตเวลาท้องถิ่น ณ สถานที่หนึ่งประชุมทุกจันทร์ 15:00 เวลาไทยเวลาท้องถิ่น + ชื่อโซน

วันเกิดเป็นตัวอย่างคลาสสิก ถ้าเก็บวันเกิด 28 ก.พ. เป็น 1990-02-28 00:00+07 ค่าที่เก็บจริงคือ 1990-02-27 17:00 UTC เซิร์ฟเวอร์ที่ตั้งโซนเป็น UTC จะแสดงวันเกิดเป็น 27 ก.พ. ทันที วันเกิดไม่มีเวลา จึงต้องเก็บเป็น date

วันทำการก็เหมือนกัน “ส่งของภายใน 3 วันทำการ” คำนวณจากวันในปฏิทินของประเทศไทย เอา instant ตอนสั่งซื้อมาแปลงเป็นวันที่ในโซน Asia/Bangkok ก่อน แล้วนับวันจากตารางวันหยุดที่เก็บเป็น date

กฎสามข้อ

  1. instant เก็บเป็น UTC ใน Postgres ใช้ timestamptz ใน API ส่งเป็น ISO 8601 ที่มี Z หรือออฟเซ็ตเสมอ เช่น 2026-10-22T03:30:00Z
  2. เก็บชื่อโซน IANA ของผู้ใช้ เช่น Asia/Bangkok, Europe/London ไว้ในโปรไฟล์ ไม่ใช่ +07:00 ออฟเซ็ตบอกแค่ค่าในวันนั้น ชื่อโซนบอกกฎทั้งปี รวม DST
  3. แปลงเป็นเวลาท้องถิ่นตอนแสดงผลเท่านั้น ที่ขอบสุดของระบบ (หน้าเว็บ, อีเมล, PDF) ข้างในระบบคิดเป็น UTC ทั้งหมด

แนวคิดเดียวกับการเก็บเงินเป็นหน่วยเล็กสุดแล้วจัดรูปตอนแสดง เก็บค่ากลางที่ไม่กำกวม ส่วนรูปแบบที่คนอ่านเป็นงานของชั้นแสดงผล

เก็บ UTC, จำโซนของผู้ใช้, แปลงที่ขอบ สามข้อนี้แก้บั๊กเวลาได้เกือบทั้งหมด

รับ 10:30 (+07:00)ผู้ใช้กรอกตามเวลาไทยเก็บ 03:30 UTCค่าเดียวในฐานข้อมูลแสดงตามโซนคนดูไทย 10:30 · ลอนดอน 04:30 (BST)
เก็บค่าเดียวเป็น UTC แล้วแต่ละคนเห็นเวลาตามโซนของตัวเองตอนแสดง

PostgreSQL: timestamptz ไม่ได้เก็บโซน

ชื่อ timestamp with time zone ชวนเข้าใจผิด เอกสาร Postgres บอกชัดว่าค่าถูกแปลงเป็น UTC ตอนรับเข้า และ ไม่เก็บโซนเดิมไว้ ตอนอ่านออกจะแปลงเป็นโซนตามค่า TimeZone ของ session นั้น ส่วน timestamp (ไม่มี tz) จะ ทิ้งโซนที่ส่งมาเงียบ ๆ จึงไม่ควรใช้กับ instant

SET TIME ZONE 'UTC';
CREATE TABLE bookings (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  starts_at timestamptz NOT NULL,
  user_tz   text NOT NULL DEFAULT 'Asia/Bangkok'
);
INSERT INTO bookings (starts_at) VALUES ('2026-10-22 10:30+07');

SELECT starts_at, starts_at AT TIME ZONE user_tz AS local_time FROM bookings;
-- starts_at              | local_time
-- 2026-10-22 03:30:00+00 | 2026-10-22 10:30:00

SET TIME ZONE 'Asia/Bangkok';
SELECT starts_at FROM bookings;
-- 2026-10-22 10:30:00+07   (ค่าเดิม แสดงต่างกันตาม session)

AT TIME ZONE กับ timestamptz ให้ผลเป็นเวลาท้องถิ่นแบบไม่มีโซน ใช้สำหรับแสดงหรือหาว่า “เป็นวันที่เท่าไรในไทย” เช่น (starts_at AT TIME ZONE 'Asia/Bangkok')::date สำหรับรายงานรายวัน ถ้าไม่แปลงก่อน ยอดตั้งแต่ 00:00 ถึง 06:59 เวลาไทยจะไปตกวันก่อนหน้า (เพราะยังเป็นวันก่อนใน UTC) ข้อมูลอนุกรมเวลาจำนวนมากก็ใช้หลักนี้ ดูต่อในบทความ TimescaleDB

บวก 24 ชั่วโมง ไม่เท่ากับพรุ่งนี้

ไทยไม่มี DST (daylight saving time การเลื่อนนาฬิกาตามฤดู) แต่ลูกค้าหรือทีมที่อยู่อเมริกาและยุโรปมี ปี 2026 นิวยอร์กเลื่อนนาฬิกาไปข้างหน้าวันที่ 8 มี.ค. ทำให้วันนั้นมีแค่ 23 ชั่วโมง

ตัวอย่างทีละขั้น: แจ้งเตือนรายวัน 09:00 เวลานิวยอร์ก เริ่ม 7 มี.ค. 2026

  1. 7 มี.ค. 09:00 ที่นิวยอร์ก ออฟเซ็ต -05:00 = 14:00 UTC
  2. บวก 24 ชั่วโมง = 8 มี.ค. 14:00 UTC
  3. 8 มี.ค. นิวยอร์กเปลี่ยนเป็น -04:00 แล้ว 14:00 UTC จึงเป็น 10:00 เวลาท้องถิ่น ผู้ใช้ได้แจ้งเตือนช้าไปหนึ่งชั่วโมง
  4. บวก 1 วันตามปฏิทิน = 8 มี.ค. 09:00 ที่นิวยอร์ก = 13:00 UTC ถูกต้อง ห่างจากครั้งก่อนแค่ 23 ชั่วโมงจริง

ทั้งสองแบบถูกในงานของมัน token หมดอายุใน 24 ชั่วโมงใช้แบบที่ 2 นัดหมายที่คนจำเป็นเวลาบนนาฬิกาใช้แบบที่ 4 Postgres ทำแบบที่ 4 ให้ด้วย interval '1 day' ตามโซนของ session หรือระบุโซนตรง ๆ ด้วย date_add() (มีตั้งแต่ PostgreSQL 16)

SELECT date_add('2026-03-07 09:00-05'::timestamptz, interval '1 day', 'America/New_York');
-- = 2026-03-08 13:00 UTC = 09:00 เวลานิวยอร์ก

อีกตัวอย่างที่ใกล้ตัว: ประชุมทุกสัปดาห์ 15:00 เวลาไทยกับทีมลอนดอน ลอนดอนเลิก DST วันที่ 25 ต.ค. 2026

วันที่เวลาไทยลอนดอนห่างกัน
24 ต.ค. 202615:0009:00 (BST, +01:00)6 ชั่วโมง
26 ต.ค. 202615:0008:00 (GMT, +00:00)7 ชั่วโมง

นัดประจำแบบนี้จึงต้องเก็บเป็น “15:00 ที่ Asia/Bangkok ทุกวันจันทร์” แล้วคำนวณ instant ของแต่ละครั้งตอนใกล้ถึง ถ้าคำนวณ instant ล่วงหน้าทั้งปีแล้วเก็บไว้ เมื่อประเทศไหนเปลี่ยนกฎเวลา (เกิดขึ้นจริงเป็นระยะ และมาถึงเราผ่านการอัปเดต tzdata) นัดที่เก็บไว้จะผิดทั้งชุด

Python: zoneinfo และกับดักบน Windows

zoneinfo อยู่ใน stdlib ตั้งแต่ Python 3.9

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
d = datetime(2026, 3, 7, 9, 0, tzinfo=ny)

print(d + timedelta(hours=24))   # 2026-03-08 09:00:00-04:00  (บวกตามนาฬิกาท้องถิ่น)
print((d.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(ny))
                                 # 2026-03-08 10:00:00-04:00  (บวก 24 ชั่วโมงจริง)

bkk = ZoneInfo("Asia/Bangkok")
t = datetime(2026, 10, 22, 3, 30, tzinfo=timezone.utc).astimezone(bkk)
print(t)                         # 2026-10-22 10:30:00+07:00

สังเกตว่า timedelta(hours=24) บน datetime ที่มีโซน ZoneInfo บวกตามเวลาบนนาฬิกา ไม่ใช่เวลาที่ผ่านไปจริง ถ้าต้องการ 24 ชั่วโมงจริงให้แปลงเป็น UTC ก่อนบวก

กับดักที่เจอตอนเขียนบทความนี้: บน Windows โค้ดข้างบนพังด้วย ZoneInfoNotFoundError: 'No time zone found with key America/New_York' เพราะ Windows ไม่มีฐานข้อมูล IANA ในเครื่อง เอกสาร Python แนะนำให้ใส่ tzdata เป็น dependency ของโปรเจกต์ที่ต้องรันข้ามระบบ

pip install tzdata

JavaScript: Date วันนี้ และ Temporal

Date เก็บ instant เป็นมิลลิวินาทีจาก epoch ซึ่งดี แต่การ parse สตริงมีกับดัก เอกสาร MDN ระบุว่า สตริงที่มีแต่วันที่ถือเป็น UTC แต่ สตริงวันที่+เวลาที่ไม่มีโซนถือเป็นเวลาเครื่อง

// รันบนเครื่องที่ตั้งโซนเป็น Asia/Bangkok
new Date('2026-10-22').toISOString();        // '2026-10-22T00:00:00.000Z'
new Date('2026-10-22T00:00').toISOString();  // '2026-10-21T17:00:00.000Z'

สองบรรทัดหน้าตาคล้ายกันแต่ห่างกัน 7 ชั่วโมง และผลบรรทัดที่สองเปลี่ยนตามเครื่องที่รัน

ทางออกระยะยาวคือ Temporal API ใหม่ที่แยกชนิดตามความหมายชัดเจน: Temporal.Instant (instant), Temporal.PlainDate (วันในปฏิทิน), Temporal.ZonedDateTime (เวลาท้องถิ่น + ชื่อโซน)

const start = Temporal.ZonedDateTime.from('2026-03-07T09:00[America/New_York]');
start.add({ days: 1 }).toString();   // '2026-03-08T09:00:00-04:00[America/New_York]'
start.add({ hours: 24 }).toString(); // '2026-03-08T10:00:00-04:00[America/New_York]'

สถานะ ณ ตุลาคม 2026: proposal อยู่ Stage 4 แล้ว ใช้ได้ใน Chrome/Edge 144, Firefox 139 และเปิดเป็นค่าเริ่มต้นใน Node.js 26 แต่ Safari รุ่น stable ยังไม่มี (มีใน Technology Preview) MDN จึงยังไม่นับเป็น Baseline งานบนเว็บตอนนี้ใช้ polyfill เช่น temporal-polyfill หรือ @js-temporal/polyfill แล้วโหลดเฉพาะเมื่อ typeof Temporal === 'undefined'

พ.ศ. คือเรื่องการแสดงผล

ปีพุทธศักราช = ค.ศ. + 543 (2026 + 543 = 2569) เก็บในฐานข้อมูลเป็น ค.ศ. เสมอ แล้วแปลงตอนแสดง ไม่ต้องบวกเลขเอง Intl ทำให้ และ locale th-TH ใช้ปฏิทินพุทธเป็นค่าเริ่มต้น

const d = new Date('2026-10-22T03:30:00Z');
const opt = { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Bangkok' };
new Intl.DateTimeFormat('th-TH', opt).format(d);              // '22 ต.ค. 2569 10:30'
new Intl.DateTimeFormat('th-TH-u-ca-gregory', opt).format(d); // '22 ต.ค. 2026 10:30'

ถ้าผู้ใช้กรอกปี พ.ศ. ในฟอร์ม ให้แปลงเป็น ค.ศ. ที่ชั้นรับข้อมูลทันที และตรวจช่วงปีไว้ (ปีเกิด 2569 กับ 2026 ต่างกัน 543 ปี เป็นบั๊กที่เจอบ่อย) ข้อควรรู้สำหรับข้อมูลเก่า: ก่อนปี 1941 ไทยขึ้นปีใหม่วันที่ 1 เมษายน วันที่ 1 ม.ค. ถึง 31 มี.ค. ของช่วงนั้นต่างจาก ค.ศ. 542 ไม่ใช่ 543 ระบบทั่วไปไม่เจอ แต่งานเอกสารประวัติศาสตร์ต้องระวัง

ข้อผิดพลาดที่เจอบ่อย

  • ใช้ timestamp แทน timestamptz แล้วเซิร์ฟเวอร์สองเครื่องตั้งโซนต่างกัน ข้อมูลปนกันโดยแยกไม่ออก
  • เก็บวันเกิดเป็น timestamp แล้ววันเลื่อนเมื่อแสดงในโซนอื่น
  • เก็บออฟเซ็ต +07:00 แทนชื่อโซน ใช้ได้กับไทยเพราะไม่มี DST แต่พังทันทีที่มีผู้ใช้ต่างประเทศ
  • รายงานรายวันตัดวันด้วย UTC ยอดเช้ามืดเวลาไทยไปตกวันก่อน ตัดวันด้วย AT TIME ZONE ของธุรกิจ
  • บวก 86,400 วินาทีแทนบวก 1 วัน สำหรับงานที่คนจำเป็นเวลาบนนาฬิกา
  • ปล่อย tzdata เก่า บน Windows ต้องมีแพ็กเกจ tzdata ใน Docker image ก็ต้องมี และควรอัปเดตเมื่อมีประเทศเปลี่ยนกฎ
  • เก็บปี พ.ศ. ลงฐานข้อมูล แล้ว sort/คำนวณอายุผิด 543 ปี

instant เป็น UTC, วันเกิดเป็น date, นัดในอนาคตเป็นเวลาท้องถิ่นพร้อมชื่อโซน ถามว่าค่านี้คืออะไรก่อนเลือกชนิดข้อมูล

แหล่งอ้างอิง

คำถามที่พบบ่อย

ประเทศไทยไม่มี DST ทำไมต้องสนใจ

เพราะผู้ใช้ ลูกค้า เซิร์ฟเวอร์ และบริการภายนอกไม่ได้อยู่ไทยทั้งหมด นัดประชุมกับทีมลอนดอนหรือส่งแจ้งเตือนให้ลูกค้าในอเมริกาจะเลื่อนหนึ่งชั่วโมงปีละสองครั้ง ถ้าคำนวณด้วยออฟเซ็ตตายตัว

เก็บ +07:00 แทน Asia/Bangkok ได้ไหม

สำหรับเวลาที่ผ่านไปแล้วได้ แต่สำหรับผู้ใช้และนัดในอนาคตไม่ควร ออฟเซ็ตบอกแค่ว่าตอนนั้นห่างจาก UTC เท่าไร ชื่อโซน IANA บอกกฎทั้งหมด รวม DST และการเปลี่ยนกฎในอนาคตที่มากับฐานข้อมูล tzdata

ตอนนี้ใช้ Temporal ใน JavaScript ได้หรือยัง

ณ ตุลาคม 2026 Temporal อยู่ Stage 4 แล้ว ใช้ได้ใน Chrome/Edge 144, Firefox 139 และ Node.js 26 ขึ้นไป แต่ Safari รุ่น stable ยังไม่มี งานบนเว็บจึงยังต้องใช้ polyfill

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดกลาง

Python 3.15: lazy import, frozendict และ UTF-8 ค่าเริ่มต้น แก้ไฟล์ไทยบน Windows

Python 3.15 ออกแล้ว lazy import ทำให้ CLI เปิดเร็วขึ้นหลายเท่า frozendict ใช้เป็น key ได้ และ open() อ่านเขียน UTF-8 เสมอ ไฟล์ภาษาไทยบน Windows ไม่เพี้ยนอีก

อ่าน 12 นาที
#โค้ดกลาง

Temporal ใน JavaScript ใช้งานจริง: บวกเดือน คำนวณอายุ และ polyfill สำหรับ Safari

Temporal เป็น Stage 4 ใช้ได้ใน Chrome 144, Firefox 139 และ Node.js 26 แล้ว โค้ดที่ใช้บ่อยแทน Date ต่อกับโค้ดเดิม แสดง พ.ศ. และโหลด polyfill เฉพาะ Safari

อ่าน 13 นาที
#โค้ดกลาง

Background job และ queue: ย้ายงานช้าออกจาก request พร้อม retry ที่ไม่ส่งซ้ำ

ส่งอีเมล ย่อรูป เรียก AI ไม่ควรทำใน request ทำ job queue ด้วย Postgres SKIP LOCKED พร้อม retry แบบ backoff, visibility timeout, dead-letter และทำให้ job รันซ้ำได้ปลอดภัย

อ่าน 15 นาที