งูสีเขียวต้นฉบับเลื้อยเชื่อมแผ่นปฏิทินไร้ตัวเลขกับนาฬิกาและป้ายเขตเวลาอย่างเป็นระเบียบ

Python datetime.fromisoformat: รับเวลา ISO 8601 โดยไม่ลืม timezone

พี่โชว์Engineering

API มักส่งเวลาอย่าง 2026-10-09T10:00:00+07:00 หรือปิดท้ายด้วย Z ถ้าเราตัด string เองหรือใช้ format เดียวกับทุกแหล่ง จะพังเมื่อมีเศษวินาทีหรือ offset รูปแบบอื่น datetime.fromisoformat อ่านรูปแบบ ISO 8601 ที่ Python รองรับโดยไม่ต้องเขียน parser ใหม่ แต่สิ่งสำคัญกว่าการ parse ผ่านคือรู้ว่าเวลานั้น มี timezone หรือยัง ครับ

WHY: เวลาเดียวกันเขียนได้หลายหน้าตา

เวลา 10 โมงกรุงเทพฯ กับ 3 โมง UTC คือ instant เดียวกัน เหมือนนัดหมายใบเดียวที่เขียนตามนาฬิกาคนละเรือน Offset บอกวิธีแปลงกลับไปยังเส้นเวลาเดียว ถ้า input ไม่มี offset เราได้ naive datetime ซึ่ง Python ไม่รู้ว่าผู้ส่งหมายถึงกรุงเทพฯ, UTC หรือเวลาท้องถิ่นของ server การเดาเงียบ ๆ ทำให้ข้อมูลเลื่อนได้หลายชั่วโมง

ดังนั้น boundary ของระบบควรกำหนด contract: รับเฉพาะ aware datetime, normalize เป็น UTC ภายใน และแปลง timezone เฉพาะตอนแสดงผล

HOW: parse แล้วปฏิเสธค่าที่ไม่มี timezone

import unittest
from datetime import datetime, timezone


def parse_instant(value: str) -> datetime:
    parsed = datetime.fromisoformat(value)
    if parsed.tzinfo is None or parsed.utcoffset() is None:
        raise ValueError("timestamp must include a UTC offset")
    return parsed.astimezone(timezone.utc)


class ParseInstantTests(unittest.TestCase):
    def test_offset_and_z_refer_to_the_same_instant(self):
        bangkok = parse_instant("2026-10-09T10:00:00+07:00")
        utc = parse_instant("2026-10-09T03:00:00Z")
        self.assertEqual(bangkok, utc)
        self.assertEqual(utc.isoformat(), "2026-10-09T03:00:00+00:00")

    def test_rejects_naive_timestamp(self):
        with self.assertRaisesRegex(ValueError, "UTC offset"):
            parse_instant("2026-10-09T10:00:00")

    def test_rejects_invalid_calendar_date(self):
        with self.assertRaises(ValueError):
            parse_instant("2026-02-30T10:00:00+07:00")


if __name__ == "__main__":
    unittest.main()

ตัวอย่างคืน aware datetime ที่ UTC เสมอ จึงเปรียบเทียบและ sort ข้าม timezone ได้ตรงความหมาย การใช้ timezone.utc เป็นค่า explicit ช่วยให้ database layer ไม่แอบตีความตาม timezone ของเครื่องที่ deploy

อย่าอ้างว่ารับ ISO 8601 ทุกแบบ

เอกสาร Python ระบุข้อยกเว้นบางส่วน เช่น reduced precision date และ ordinal date บางรูปแบบไม่รองรับ ความจริงที่ถูกต้องคือ fromisoformat รับ ISO 8601 ตามชุดที่เอกสารของ Python รุ่นนั้นรองรับ ไม่ใช่ validator มาตรฐานทุกกรณี ถ้า protocol กำหนด RFC 3339 แคบกว่า ควร validate contract เพิ่มและมี test ของรูปแบบที่ยอมรับจริง

อีกจุดคือ daylight saving time ชื่อเขตอย่าง America/New_York มีประวัติการเปลี่ยน offset แต่ string -04:00 เป็นเพียง fixed offset ณ instant นั้น ถ้าต้องคำนวณ “ทุกวัน 9 โมงตามเมือง” ให้เก็บ zone identifier ด้วย zoneinfo; อย่าใช้ offset เดิมบวก 24 ชั่วโมงแล้วคิดว่าเป็นเวลาท้องถิ่นวันถัดไปเสมอ

ควรเก็บอะไรในฐานข้อมูล

สำหรับ event ที่เกิดขึ้นแล้ว ให้เก็บ instant แบบ UTC และถ้าบริบทธุรกิจต้องรู้เขตเดิมให้เก็บ zone identifier แยก สำหรับตารางนัดหมายในอนาคต กฎ timezone อาจเปลี่ยน จึงควรเก็บ local wall time + zone + policy ตอนเวลาซ้ำหรือเวลาหายช่วง DST ไม่ใช่แค่ timestamp แปลงครั้งเดียว

อย่าจับ ValueError แล้วแทนด้วยเวลาปัจจุบัน เพราะจะทำให้ข้อมูลเสียดูเหมือนสำเร็จ ควรตอบ validation error พร้อมชื่อ field และตัวอย่าง format โดยไม่สะท้อน payload ลับทั้งหมดลง log

Recap

  • parse ด้วย fromisoformat ตามรูปแบบที่ Python รองรับ
  • ปฏิเสธ naive datetime ที่ boundary ถ้า contract ต้องการ instant
  • normalize เป็น UTC ก่อนเปรียบเทียบหรือเก็บ event
  • ใช้ zoneinfo เมื่องานผูกกับเวลาท้องถิ่น ไม่ใช่ fixed offset
  • ทดสอบ Z, positive/negative offset, naive และวันที่ผิด

การ parse ผ่านเป็นแค่ประตูแรกครับ สัญญาเรื่อง timezone ต่างหากที่กันบั๊ก “เวลาเลื่อนไปเจ็ดชั่วโมง” ซึ่งมักโผล่เมื่อย้ายเครื่องหรือเชื่อมระบบใหม่