งูการ์ตูนสีเขียวยื่นหน้าดูกล่องแฟ้มสีเขียวที่บรรจุตารางเอกสาร ข้างกองกระดาษตารางที่กระจายบนโต๊ะไม้ในห้องทำงานสว่างที่มีต้นไม้

ส่งออก CSV ภาษาไทยให้ Excel อ่านง่ายด้วย Python utf-8-sig

น้องกายEngineering

รายงานยอดจ่ายถูกทุกบาท แต่พอฝ่ายบัญชีดับเบิลคลิก CSV ใน Excel ชื่อพนักงานกลับอ่านไม่ออก งานส่งออกก็ยังถือว่าไม่จบครับ จุดที่ต้องตกลงกันคือ encoding หรือวิธีแปลงข้อความเป็นไบต์ในไฟล์ บทความนี้จะทำไฟล์ภาษาไทยแบบ UTF-8 พร้อม BOM สำหรับส่งให้คนเปิดใน Excel และมีเทสต์ตรวจทั้งไฟล์กับข้อมูลที่อ่านกลับ

WHY: ส่งรายงานแล้วคนรับต้องอ่านต่อได้

  • ระบุ encoding ในโค้ดให้ชัด แทนการปล่อยให้ขั้นตอนส่งออกขึ้นอยู่กับค่าเริ่มต้นของเครื่อง
  • แยกปัญหาอักษรไทยออกจากปัญหารูปแบบ CSV เช่น เครื่องหมายคอมมาภายในช่องและการขึ้นบรรทัดใหม่
  • ตรวจอัตโนมัติว่าชื่อ ยอดบาท และวันที่ยังตรงกับต้นฉบับ ก่อนส่งไฟล์ให้ทีมอื่น

พฤติกรรมที่พบกันทั่วไปคือ Excel บน Windows มักใช้ BOM เป็นเบาะแสเดา encoding เมื่อเปิดไฟล์โดยตรง ข้อนี้เป็นข้อสังเกตในการใช้งาน ไม่ใช่คำรับประกันจากเอกสาร Python หรือผลทดสอบ Excel ในบทความนี้ จึงควรลองดับเบิลคลิกไฟล์บนเครื่องปลายทางจริงด้วย โดยเฉพาะเมื่อส่งรายงานเป็นครั้งแรก

ทำงานยังไง

CSV คือข้อความที่แบ่งข้อมูลเป็นแถวและช่อง เราให้ csv.DictWriter รับแต่ละแถวเป็น dictionary ซึ่งเก็บคู่ชื่อคอลัมน์กับค่า ส่วน fieldnames ระบุลำดับคอลัมน์ ตอนอ่าน csv.DictReader จะใช้แถวแรกเป็นชื่อคีย์หากเราไม่ส่ง fieldnames เข้าไป ตาม เอกสาร csv

BOM ย่อจาก byte order mark สำหรับ UTF-8 มันทำหน้าที่เป็นลายเซ็นบอกชนิดข้อความ เปรียบเหมือนติดป้าย “เอกสารภาษาไทย” ไว้หน้าซอง คนรับมีเบาะแสก่อนเปิดอ่าน แต่ป้ายนั้นไม่ควรกลายเป็นส่วนหนึ่งของชื่อผู้รับในระบบเรา

codec คือชุดกติกาแปลงข้อความกับไบต์ ตัว utf-8-sig เขียน BOM เมื่อเข้ารหัส และข้าม BOM ที่ต้นข้อมูลเมื่อถอดรหัส ตัวอย่างด้านล่างตรวจด้วยว่าลายเซ็นนั้นคือไบต์ EF BB BF และเมื่อใช้ utf-8 ธรรมดาอ่านไฟล์เดียวกัน อักขระ \ufeff จะยังติดอยู่หน้าคีย์ ชื่อ ดูเรื่อง codec และ BOM ได้จาก เอกสาร codecs

อีกค่าที่ต้องใส่คือ newline='' ทั้งฝั่งอ่านและเขียน เพื่อให้โมดูล csv จัดการบรรทัดเอง เอกสาร csv กำหนดให้เปิด file object ด้วยค่านี้ เราจะทดสอบช่องหมายเหตุที่มีการขึ้นบรรทัดใหม่ด้วย จึงไม่ได้เช็กแค่ไฟล์สวย ๆ ที่ทุกช่องมีคำเดียว

หน้าที่ทั้งสองแยกกันชัดครับ encoding ดูแลการแปลงอักษร ส่วน newline ดูแลการจัดการอักขระขึ้นบรรทัดใหม่ เปลี่ยนค่าแรกไม่ได้ทดแทนการตั้งค่าหลัง และการเลือก dialect ชื่อ excel ก็ไม่ได้เพิ่ม BOM ให้เอง

โค้ดตัวอย่างพร้อมเทสต์

บันทึกเป็น thai_csv_test.py แบบ UTF-8 แล้วรัน python3 -I -B thai_csv_test.py ใช้เฉพาะไลบรารีมาตรฐาน ข้อมูลทั้งหมดเป็นข้อมูลสมมติ ยอดบาทเก็บเป็นข้อความเพื่อคงรูปแบบทศนิยม ส่วนวันที่ พ.ศ. เก็บเป็นข้อความตามที่ต้องการส่งออก

import csv
import tempfile
import unittest
from pathlib import Path


FIELDS = ["ชื่อ", "ยอดบาท", "วันที่", "หมายเหตุ"]
ROWS = [
    {
        "ชื่อ": "กานต์ ใจดี",
        "ยอดบาท": "1250.00",
        "วันที่": "20/10/2569",
        "หมายเหตุ": "ค่าเดินทาง, รอบเช้า",
    },
    {
        "ชื่อ": "มะลิ สุขใจ",
        "ยอดบาท": "890.50",
        "วันที่": "21/10/2569",
        "หมายเหตุ": 'รับเอกสาร "ต้นฉบับ"\nที่สาขาบางนา',
    },
]


def export_csv(path, rows):
    with open(path, 'w', newline='', encoding='utf-8-sig') as file:
        writer = csv.DictWriter(file, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(rows)


def import_csv(path):
    with open(path, 'r', newline='', encoding='utf-8-sig') as file:
        return list(csv.DictReader(file))


class ThaiCsvTests(unittest.TestCase):
    def setUp(self):
        self.folder = tempfile.TemporaryDirectory()
        self.addCleanup(self.folder.cleanup)
        self.path = Path(self.folder.name) / "ยอดจ่าย.csv"
        export_csv(self.path, ROWS)

    def test_file_starts_with_utf8_bom(self):
        self.assertTrue(self.path.read_bytes().startswith(b'\xef\xbb\xbf'))

    def test_round_trip_preserves_all_rows(self):
        restored = import_csv(self.path)
        self.assertEqual(restored, ROWS)
        self.assertEqual(list(restored[0]), FIELDS)
        self.assertNotIn('\ufeffชื่อ', restored[0])

    def test_plain_utf8_keeps_bom_in_first_header(self):
        with open(self.path, 'r', newline='', encoding='utf-8') as file:
            reader = csv.DictReader(file)
            rows = list(reader)
            self.assertEqual(reader.fieldnames[0], '\ufeffชื่อ')
        self.assertEqual(rows[0]['\ufeffชื่อ'], ROWS[0]['ชื่อ'])
        self.assertNotIn('ชื่อ', rows[0])


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

ผมรันตัวอย่างนี้ด้วย Python 3.14.7 แล้ว เทสต์ทั้ง 3 รายการผ่านครับ

เทสต์แรกอ่านไบต์ดิบ จึงจับได้ว่ามี BOM จริง เทสต์ที่สองเป็น round-trip หรือการเขียนแล้วอ่านกลับมาเทียบต้นฉบับ ซึ่งครอบคลุมทั้งคอมมา เครื่องหมายคำพูด และบรรทัดใหม่ในช่องข้อมูล เทสต์สุดท้ายตั้งใจใช้ encoding ที่ไม่ข้าม BOM เพื่อแสดงเหตุผลที่การเรียก row['ชื่อ'] อาจหา key ไม่เจอ ทั้งที่มองหัวตารางแล้วเหมือนปกติ

TemporaryDirectory ทำให้ไฟล์ทดสอบอยู่ในโฟลเดอร์ชั่วคราว และลงทะเบียนล้างผ่าน addCleanup เมื่อเทสต์จบ การรันชุดนี้จึงไม่ทิ้งรายงานไว้ให้ส่งต่อ ถ้าจะสร้างไฟล์ใช้งานจริง ให้นำ export_csv ไปเรียกด้วยพาธปลายทาง เช่น export_csv('ยอดจ่าย.csv', ROWS) โดยโหมด 'w' จะเขียนทับไฟล์เดิมที่พาธนั้น

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

จุดที่คนพลาดบ่อย

  • เขียนเป็น utf-8-sig แต่อ่านด้วย utf-8: หัวช่องแรกจะเป็น \ufeffชื่อ ตามเทสต์ แก้ที่จุดเปิดไฟล์เป็น encoding='utf-8-sig' อย่าเปลี่ยนชื่อคอลัมน์ทั้งระบบเพื่อหลบอักขระที่มองไม่เห็น
  • ลืม newline='': ใส่ไว้ทั้งขาอ่านและขาเขียนตามข้อกำหนดของโมดูล csv และใช้ข้อมูลที่มีบรรทัดใหม่ในช่องเป็นกรณีทดสอบด้วย แค่เปิดไฟล์แล้วไม่พบข้อผิดพลาดยังตรวจข้อมูลได้ไม่ครบ
  • ต่อข้อความด้วยคอมมาเอง: หมายเหตุ ค่าเดินทาง, รอบเช้า อาจถูกแยกเป็นหลายช่อง ให้ DictWriter จัดการ quoting หรือการครอบช่องด้วยเครื่องหมายคำพูดแทน ตาม กติกาของ csv
  • คิดว่า CSV เก็บชนิดข้อมูลครบ: reader แบบปกติคืนค่าเป็นข้อความ ตัวอย่างจึงเก็บยอดและวันที่เป็นข้อความตั้งแต่ต้น หากต้องคำนวณยอดต่อ ให้แปลงและตรวจรูปแบบในขั้นตอนรับข้อมูล อย่าใช้เพียงหน้าตาของค่าเป็นตัวตัดสิน
  • ใช้ None แล้วหวังว่าจะอ่านกลับเหมือนเดิม: writer เขียน None เป็นช่องว่าง ซึ่งแยกจากข้อความว่างเดิมไม่ได้ ตาม เอกสาร csv ถ้าต้องแยกสองความหมายนี้ ให้ตกลงค่าตัวแทนกับผู้รับก่อนส่งออก

ใช้ตรงไหนในงานจริง

  • ส่งยอดเบิกหรือค่าเดินทางให้ฝ่ายบัญชี โดยมีชื่อภาษาไทยและจำนวนบาทที่จัดรูปแบบแล้ว
  • ทำปุ่มดาวน์โหลดรายงานให้เจ้าหน้าที่เปิดตรวจใน Excel โดยกำหนดหัวคอลัมน์และลำดับให้คงที่
  • รับไฟล์ส่งออกกลับเข้าระบบ แล้วตรวจชื่อคีย์ก่อนนำไปจับคู่กับคอลัมน์ในฐานข้อมูล
  • เพิ่มเทสต์ให้กระบวนการส่งออก เมื่อเปลี่ยนข้อมูลหรือปรับฟังก์ชัน จะตรวจเจอ BOM ที่หายหรือข้อมูลที่อ่านกลับไม่ตรง

เทียบกับทางเลือกอื่น

แนวทางดีตรงไหนระวังอะไร
utf-8-sig + DictWriterมี BOM และผูกค่ากับชื่อคอลัมน์ชัดเจนผู้อ่านฝั่ง Python ควรข้าม BOM ด้วย codec เดียวกัน
utf-8-sig + csv.writerเหมาะกับข้อมูลที่เตรียมเป็นลิสต์แต่ละแถวต้องรักษาลำดับช่องและเขียนหัวตารางเอง
utf-8 + DictWriterเหมาะเมื่อระบบปลายทางตกลงรับ UTF-8 โดยตรงไม่มี BOM ให้โปรแกรมใช้เป็นเบาะแสในการเดา
ต่อสตริงคั่นคอมมาเองเริ่มเขียนได้เร็วกับข้อมูลเรียบง่ายต้องรับผิดชอบคอมมา คำพูด และบรรทัดใหม่เอง

เลือกจากผู้รับเป็นหลักครับ ถ้าทั้งสองฝั่งเป็นระบบที่กำหนด encoding ชัดเจน ก็ทำตามข้อตกลงนั้น แต่ถ้าปลายทางคือคนดับเบิลคลิกรายงานใน Excel ให้เริ่มจาก UTF-8 พร้อม BOM แล้วตรวจบนเครื่องใช้งานจริง ไม่ต้องติดตั้งแพ็กเกจเพิ่มเพื่อทำขั้นตอนส่งออกนี้

Recap

  • ระบุ encoding='utf-8-sig' เมื่อเขียน CSV ภาษาไทยพร้อม BOM
  • เปิดไฟล์ให้โมดูล csv ด้วย newline='' ทั้งอ่านและเขียน
  • ใช้ DictWriter พร้อมกำหนดลำดับ fieldnames และเขียน header
  • อ่านกลับด้วย utf-8-sig เพื่อไม่ให้ \ufeff ติดชื่อคอลัมน์แรก
  • ทดสอบ BOM แบบไบต์และเทียบข้อมูลที่อ่านกลับกับต้นฉบับ
  • ใส่คอมมา คำพูด และบรรทัดใหม่ในข้อมูลเทสต์
  • ทดลองเปิดไฟล์ใน Excel บนเครื่องปลายทางก่อนใช้ส่งรายงานจริง