บทเรียน 31 · Day 7 · Goose

Goose — เปลี่ยน schema แบบย้อนกลับได้

~18 นาที · ต้องมี: บทเรียน 27 (GORM + AutoMigrate)

ในบทเรียน 27 คุณใช้ db.AutoMigrate(&Product{}) แล้วตารางก็โผล่มาเอง — สะดวกมาก
บทนี้จะพาไปดูว่า ความสะดวกนั้นแลกมาด้วยอะไร ด้วยการทดลองจริง 5 แบบบน PostgreSQL แล้วค่อยมาดูว่า goose แก้เรื่องไหนให้บ้าง
จุดที่ต้องเข้าใจให้ได้ในบทนี้มีจุดเดียว: ตารางชื่อ goose_db_version เก็บอะไรไว้บ้าง เพราะทุกกับดักในบทนี้อธิบายได้จากตารางนั้นตารางเดียว

⚙️ เตรียมเครื่องก่อนเริ่ม — ทำให้ครบ 4 ข้อนี้ ไม่งั้นจะติดกลางทาง

1) ต้องมี Docker เปิดอยู่ + ต่อ internet ได้ (บทนี้ต้องโหลด image และ go install จาก network)

2) เปิด PostgreSQL ขึ้นมาหนึ่งตัว — บทนี้ผูก port 55433 ไว้ที่เครื่องเรา (ไม่ใช่ 5432 ตามปกติ) เพื่อไม่ให้ชนกับ Postgres ตัวอื่นที่อาจเปิดอยู่แล้ว

docker run -d --name pg-shop \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=shopdb \
  -p 55433:5432 \
  postgres:16

# เช็คว่าขึ้นแล้วจริง
docker exec -it pg-shop psql -U postgres -d shopdb -c 'SELECT 1;'

3) psql คืออะไร — เป็นโปรแกรม command line ของ PostgreSQL ไว้พิมพ์ SQL ใส่ DB ตรง ๆ บทนี้ใช้มันตรวจผลตลอด ถ้าเครื่องคุณยังไม่มี ไม่ต้องติดตั้ง ใช้ตัวที่อยู่ใน container ได้เลย โดยแทน psql ทุกที่ในบทนี้ด้วย:

docker exec -it pg-shop psql -U postgres -d shopdb

คำสั่งที่ขึ้นต้นด้วย backslash คือคำสั่งของ psql เอง ไม่ใช่ SQL: \dt = ดูรายชื่อตาราง · \d ชื่อตาราง = ดูโครงสร้างตาราง · \df = ดูรายชื่อ function · \q = ออก

4) หลัง go install ต้องพิมพ์ goose ได้ — ถ้าขึ้น command not found แปลว่า $GOPATH/bin ยังไม่อยู่ใน PATH ให้เพิ่มบรรทัดนี้ใน ~/.zshrc แล้วเปิด terminal ใหม่:

export PATH="$PATH:$(go env GOPATH)/bin"

1 · migration กับ seed ต่างกันตรงไหน

migrationseed
เปลี่ยนอะไรโครงสร้าง — ตาราง · คอลัมน์ · index · constraint · triggerข้อมูล — แถวที่อยู่ในตาราง
ตัวอย่าง SQLCREATE TABLE · ALTER TABLE · CREATE INDEXINSERT INTO
ต้องรันกี่ครั้งครั้งเดียวต่อ databaseแล้วแต่ว่าเป็นข้อมูลชนิดไหน

📘 3 คำนี้จะใช้ทั้งบท รู้จักกันก่อน (ใน AutoMigrate คุณไม่เคยเห็นเพราะ GORM ทำให้เงียบ ๆ)
index = สารบัญของตาราง สร้างไว้ให้ค้นหาคอลัมน์นั้นเร็วขึ้น เช่นสร้าง index บน name แล้ว WHERE name = 'Mouse' จะเร็วขึ้นมาก · ไม่เปลี่ยนข้อมูล เปลี่ยนแค่ความเร็ว
constraint = กฎที่ DB บังคับกับข้อมูล เช่น NOT NULL (ห้ามว่าง) · UNIQUE (ห้ามซ้ำ) · PRIMARY KEY ถ้าข้อมูลผิดกฎ DB จะปฏิเสธไม่ยอมบันทึกให้
trigger = โค้ดที่ DB สั่งรันเองอัตโนมัติทุกครั้งที่มีการ insert/update/delete แถว เช่นให้อัปเดตคอลัมน์ updated_at ให้เองโดยที่ Go ไม่ต้องสั่ง

แต่คำว่า "seed" ในโลกจริงหมายถึงสองอย่างที่ต่างกันคนละเรื่อง และนี่คือจุดที่คนสับสนที่สุด:

✅ master data — เป็น migration

ข้อมูลที่ระบบขาดไม่ได้ เช่น ตาราง categories ที่มี ELEC / BOOK / FOOD
ทุก environment (เครื่องกับ DB ของแต่ละสภาพแวดล้อม — เครื่อง dev · staging · production) ต้องมีเหมือนกันเป๊ะ · ต้องอยู่ใน git · ต้องรันครั้งเดียว
→ เขียนเป็นไฟล์ migration ปกติ

❌ ข้อมูลทดสอบ — ไม่ใช่ migration

user ปลอม 100 คน สินค้า mock สำหรับเล่นในเครื่องตัวเอง
ไม่ควรโผล่ไปที่ production เด็ดขาด และรันซ้ำได้ไม่จำกัด
→ แยกเป็นสคริปต์ต่างหาก อยู่นอก goose

💡 ประโยคที่ต้องจำ: "migration เปลี่ยน schema · seed ใส่ข้อมูล" ถูกแค่ครึ่งเดียว
(schema = โครงหน้าตาของฐานข้อมูล คือมีตารางอะไรบ้าง แต่ละตารางมีคอลัมน์ชื่ออะไร ชนิดไหน — คำนี้จะโผล่ทั้งบท)
ที่ถูกกว่าคือ migration = "การเปลี่ยนแปลงที่ต้องเกิดขึ้นครั้งเดียวและต้องเกิดเหมือนกันทุกเครื่อง" ไม่ว่าสิ่งที่เปลี่ยนจะเป็นโครงสร้างหรือข้อมูล
เดี๋ยวหัวข้อ 8 จะพิสูจน์ให้ดูว่า INSERT ธรรมดา ๆ ที่ไม่มีอะไรกันข้อมูลซ้ำเลย ก็รันซ้ำแล้วข้อมูลไม่ซ้ำ — เพราะอะไร
(ON CONFLICT DO NOTHING คือท่อนที่ต่อท้าย INSERT ของ Postgres แปลว่า "ถ้าแถวนี้ชนของเดิมอยู่แล้ว ก็ข้ามไปเงียบ ๆ อย่าเพิ่ง error" — เป็นวิธีมาตรฐานที่คนใช้กันข้อมูลซ้ำ แต่หัวข้อ 8 จะให้ดูว่าในโลกของ goose เราไม่ต้องใช้มันเลย)

2 · ทำไมห้าม migrate ด้วยมือ

"ก็แค่เข้า psql แล้วพิมพ์ ALTER TABLE เอง จะยากอะไร" — มันยากตรงสองข้อนี้:

   1) DB ที่คุณกำลังจะแก้ มีข้อมูลของคนอื่นอยู่แล้ว

      ALTER TABLE products ADD COLUMN stock INT NOT NULL;
      └───┬────┘ └──┬───┘ └────┬────┘ └─┬─┘ └───┬────┘
       แก้ตาราง  ชื่อตาราง  เพิ่มคอลัมน์  ชนิด  ห้ามเป็นค่าว่าง
                                            ▲
                                            └── แถวเก่า 50,000 แถวที่มีอยู่แล้ว
                                                จะเอาค่าอะไรใส่ในคอลัมน์ใหม่นี้?
                                                เราสั่งว่า "ห้ามว่าง" แต่ไม่ได้บอกว่า
                                                ถ้าไม่มีค่าให้ใช้อะไรแทน (DEFAULT)
                                                → Postgres ปฏิเสธทั้งคำสั่ง ตารางไม่เปลี่ยน

      แก้ได้ด้วยการบอกค่าตั้งต้นไปด้วย:
      ALTER TABLE products ADD COLUMN stock INT NOT NULL DEFAULT 0;

   2) พิมพ์ไปแล้วในเครื่อง dev — ใครจำได้ว่าต้องไปพิมพ์ซ้ำที่ไหนอีก?

      dev   ──▶ ALTER แล้ว
      staging ─▶ ?
      prod  ──▶ ?          ไม่มีใครรู้ว่า DB ตัวไหนอยู่สถานะไหน

เครื่องมือ migration แก้สองข้อนี้ด้วยวิธีเดียวกัน: เปลี่ยนคำสั่ง SQL ให้กลายเป็น "ไฟล์ที่อยู่ใน git" แล้วให้ database จำเองว่ารันไฟล์ไหนไปแล้วบ้าง
พอเป็นไฟล์ ก็ได้ของแถมมาสองอย่าง: รีวิวได้ก่อน merge และ เขียนวิธีย้อนกลับติดไว้ในไฟล์เดียวกัน

🧰 เทียบให้เห็นภาพ — ต่อเติมบ้าน 3 หลังให้เหมือนกัน

สมมติคุณมีบ้าน 3 หลังที่ต้องเหมือนกันเป๊ะ — หลังที่คุณอยู่เอง (คือเครื่อง dev) · หลังตัวอย่างที่พาลูกค้าเดินดู (staging) · หลังที่ลูกค้าอยู่จริง (production)

วิธีที่ 1 — ช่างเดินเข้าไปเจาะผนังเลย ทำเสร็จก็จบ ไม่มีแบบ ไม่มีใบสั่งงาน
อาทิตย์ถัดมาไม่มีใครจำได้ว่าเจาะหลังไหนไปแล้วบ้าง ต้องเดินไปเปิดดูทีละหลัง
และเปิดดูแล้วก็ยังไม่แน่ใจอยู่ดีว่าที่เห็นคือของครบหรือขาดอะไรไป

วิธีที่ 2 — เขียนใบสั่งงานก่อนลงมือ ใบละหนึ่งงาน มีเลขกำกับหน้าใบ ให้หัวหน้าช่างตรวจก่อน แล้วแขวนสมุดไว้ที่บ้านแต่ละหลัง จดว่าหลังนี้ทำใบไหนไปแล้วบ้าง
ได้บ้านหลังใหม่มาก็แค่ไล่ทำตามใบตั้งแต่ใบแรก แล้วจะได้บ้านหน้าตาเดียวกัน — ตราบใดที่ไม่มีใครย้อนกลับไปแก้ใบเก่าที่ทำไปแล้ว
นี่คือสิ่งที่เครื่องมือ migration ทำให้ทั้งหมด — ใบสั่งงาน = ไฟล์ใน git · สมุดที่แขวนไว้ = ตารางใน DB

ที่ไม่เหมือนกัน: ช่างจริงอ่านใบสั่งงานแล้วตีความเองได้ว่าเจ้าของน่าจะหมายถึงอะไร แต่เครื่องมือ migration ไม่เคยเดาให้เลย มันรัน SQL ตามที่คุณพิมพ์ไว้เป๊ะ ๆ
ถ้าคุณเขียนคำสั่งที่ทำข้อมูลหาย มันก็ทำให้หายเงียบ ๆ โดยไม่ทักสักคำ (เห็นของจริงในหัวข้อ 9)
และที่ต้องระวังที่สุด: ช่างจริงถ้าใบสั่งงานถูกแก้ทีหลัง เขายังเดินไปดูบ้านหลังเก่าแล้วเห็นว่าไม่ตรงได้ แต่เครื่องมือ migration ไม่เคยกลับไปเทียบใบกับบ้านที่ทำไปแล้ว — ถ้ามีคนแก้ใบเก่า บ้านหลังใหม่จะได้ตามใบที่แก้ ส่วนบ้านหลังเก่ายังเป็นของเดิม และไม่มีอะไรฟ้องสักตัว (เจอของจริงในกับดัก 1)

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้คือคำที่ dev ใช้เรียกสิ่งที่เพิ่งอ่านไป · พูดให้ตรงคำ จะได้คำตอบที่ตรงกว่ามาก ไม่ว่าจะถาม senior หรือพิมพ์ถาม AI

schema
“โครงหน้าตาของฐานข้อมูล” — มีตารางอะไร แต่ละตารางมีคอลัมน์ชื่ออะไร ชนิดไหน · ถามว่า “ตอนนี้ schema ของตาราง products เป็นยังไง” จะสื่อกว่าถามว่า “ตารางนี้หน้าตายังไง”
ระวัง: เฉพาะบน PostgreSQL คำนี้ยังมีอีกความหมายคือ กล่องจัดกลุ่มตาราง — ตัวที่โผล่เป็น public. นำหน้าชื่อตารางใน output ของบทนี้ · ถ้าไม่แน่ใจว่า senior หมายถึงอันไหน ถามกลับสั้น ๆ ว่า “หมายถึงโครงตาราง หรือหมายถึง schema public”
migration
“ใบสั่งงานหนึ่งใบ” = ไฟล์เดียว 1 การเปลี่ยนแปลงที่ต้องเกิดครั้งเดียวและเหมือนกันทุก environment — จะเป็นการเปลี่ยน schema หรือใส่ข้อมูลตั้งต้นก็ได้ (แบบหลังบางคนเรียกแยกว่า data migration · ของจริงอยู่หัวข้อ 8) · ประโยคที่ใช้จริงตอนคุยงาน: “ขอแยกเป็น migration ไฟล์ใหม่ได้ไหม อย่าไปแก้ไฟล์เดิม”
environment (ย่อว่า env)
“บ้านแต่ละหลัง” — ชุดเครื่องกับ DB ของแต่ละสภาพแวดล้อม: dev (เครื่องเรา) · staging (เครื่องซ้อมก่อนขึ้นจริง) · production หรือ prod (ตัวจริงที่ลูกค้าใช้) · คำถามแรกที่ senior มักถามกลับคือ “อันนี้รันบน env ไหน”
schema drift
อาการที่ DB ของแต่ละ environment ค่อย ๆ ไม่ตรงกัน โดยไม่มีใครรู้ตัว — คือปัญหาข้อ 2 ในรูปข้างบนนั่นเอง · เวลาเล่าให้ senior ฟังให้พูดว่า “สงสัยว่า prod กับ staging มัน drift กันไปแล้ว” เขาจะเข้าใจทันทีว่าคุณกลัวอะไรอยู่

3 · AutoMigrate ของบทเรียน 27 ทำอะไรไม่ได้บ้าง

ก่อนจะไปที่ goose ต้องตอบให้ได้ก่อนว่า ทำไมของที่มีอยู่แล้วถึงไม่พอ
ผมเปิด PostgreSQL 16 ขึ้นมาหนึ่งตัว แล้วรัน AutoMigrate จริง 5 รอบ รอบละ struct ที่ต่างกันนิดเดียว

// step1 — รันครั้งแรก สร้างตาราง แล้ว insert Keyboard 1200 กับ Mouse 590
type Product struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"not null"`
    Price float64
}

// step2 — เปลี่ยนชื่อ field จาก Price เป็น Cost แล้วรัน AutoMigrate อีกที
type Product struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string `gorm:"not null"`
    Cost  float64
    Stock int
}

db.AutoMigrate(&Product{})

หลังรัน step2 — ตาราง products มีคอลัมน์อะไรบ้าง และราคา 1200 / 590 ไปอยู่ที่ไหน?

AutoMigrate step2 OK

                            Table "public.products"
 Column |  Type   | Collation | Nullable |               Default
--------+---------+-----------+----------+--------------------------------------
 id     | bigint  |           | not null | nextval('products_id_seq'::regclass)
 name   | text    |           | not null |
 price  | numeric |           |          |
 cost   | numeric |           |          |
 stock  | bigint  |           |          |

 id |   name   | price | cost | stock
----+----------+-------+------+-------
  1 | Keyboard |  1200 |      |
  2 | Mouse    |   590 |      |
(2 rows)

ได้คอลัมน์ cost ว่างเปล่ามาเพิ่ม ส่วน price ยังอยู่ที่เดิมพร้อมข้อมูลครบ
ไม่มี error ไม่มี warning — พิมพ์ OK ด้วยซ้ำ แต่โค้ดที่อ่าน Cost จะเห็นค่าว่างหมด

สาเหตุ: AutoMigrate เทียบ struct กับ schema แล้ว "เติมสิ่งที่ขาด" อย่างเดียว
มันไม่มีทางรู้ว่า cost คือ price ที่เปลี่ยนชื่อ หรือเป็นคอลัมน์ใหม่จริง ๆ — เพราะมันไม่มีประวัติให้ดู เห็นแค่ "ตอนนี้ DB เป็นแบบนี้ struct เป็นแบบนี้"

ผมไล่ทดลองต่อจนครบ นี่คือสิ่งที่ AutoMigrate ทำได้และทำไม่ได้ ทั้งหมดยืนยันด้วยการรันจริง:

สถานการณ์ผลจริง
สร้างตารางใหม่ · เพิ่ม field ใหม่✅ ทำให้ครบ
เปลี่ยน type ของคอลัมน์เดิม (int → string)✅ ทำได้จริง — เปลี่ยน Stock int เป็น Stock string ได้ stock | text · และถ้าใส่ tag gorm:"size:20" บน Name ก็ได้ name | character varying(20) (character varying(20) = ข้อความยาวไม่เกิน 20 ตัวอักษร)
เปลี่ยนชื่อ field❌ สร้างคอลัมน์ใหม่ ทิ้งของเก่าไว้ ข้อมูลไม่ตามมา
ลบ field ออกจาก struct❌ คอลัมน์ยังอยู่ตลอดไป — เอกสาร GORM ระบุเองว่าจะไม่ลบคอลัมน์ที่ไม่ได้ใช้
เพิ่ม gorm:"not null" บนคอลัมน์เดิม❌ พิมพ์ OK · exit 0 · แต่ไม่ทำอะไรเลย — Nullable ยังว่าง
มี version table ให้รู้ว่า DB ไหนอยู่สถานะไหน❌ ไม่มีเลย — \dt เห็นแค่ products ตารางเดียว

⚠️ ข้อที่อันตรายที่สุดคือข้อ "เพิ่ม not null"
คุณเติม tag เข้าไป โค้ดคอมไพล์ผ่าน AutoMigrate บอก OK โค้ดฝั่ง Go จึงเชื่อว่า field นี้ไม่มีวันว่าง แต่ DB ยังรับ NULL ได้อยู่
ข้อมูลสกปรกไหลเข้าเงียบ ๆ แล้วไปโผล่เป็น nil pointer ตอนอ่านออกมาอีกทีหนึ่ง

AutoMigrate step3 OK
EXIT=0
 Column |  Type   | Collation | Nullable |               Default
--------+---------+-----------+----------+--------------------------------------
 cost   | numeric |           |          |

และเมื่อ AutoMigrate ตัดสินใจผิด คุณไม่มีทางย้อน เพราะไม่มีที่ไหนบันทึกไว้ว่ามันทำอะไรไป ลองดูตอนที่มันเจอข้อมูลแปลงไม่ได้ (คอลัมน์ stock เป็น text แล้วมีคำว่า ten อยู่ พอเปลี่ยน struct กลับเป็น int):

2026/08/18 19:54:58 /..../step5/main.go:20 ERROR: invalid input syntax for type bigint: "ten" (SQLSTATE 22P02)
[3.165ms] [rows:0] ALTER TABLE "products" ALTER COLUMN "stock" TYPE bigint USING "stock"::bigint
AutoMigrate ERROR: ERROR: invalid input syntax for type bigint: "ten" (SQLSTATE 22P02)

สังเกตบรรทัดกลาง — ALTER TABLE ... USING "stock"::bigint คือ SQL ที่ GORM แต่งขึ้นเองตอน runtime โดยไม่มีใครเคยเห็นมาก่อน
นี่คือความต่างที่แท้จริง: migration ของ goose คือ "สคริปต์ที่รีวิวได้ก่อน merge" ส่วน AutoMigrate คือ "SQL ลึกลับที่เกิดตอนแอป start"
แอปตัวนี้พังตอน boot บน production โดยที่ไม่มีใครเคยเห็น ALTER บรรทัดนั้นในโค้ดรีวิวเลยสักครั้ง

4 · ติดตั้ง goose แล้วสร้างไฟล์แรก

go install github.com/pressly/goose/v3/cmd/goose@latest
which goose      # ติดตั้งลงที่ไหน
goose --version  # ได้เวอร์ชันไหนมา
/Users/tuwanon/go/bin/goose
goose version: v3.27.3

goose เป็น CLI (โปรแกรมที่พิมพ์เรียกใน terminal) แยกต่างหาก ไม่ใช่ library ที่ import เข้าไปในโค้ดโปรเจกต์ — binary ไปอยู่ที่ $GOPATH/bin เหมือนเครื่องมือ Go ตัวอื่น ๆ
ก่อนใช้ต้องบอกมันสามอย่าง: driver อะไร (driver = ตัวเชื่อมที่รู้วิธีคุยกับ DB ยี่ห้อนั้น ๆ ของเราคือ postgres) · ต่อ DB ไหน · ไฟล์อยู่โฟลเดอร์ไหน ซึ่งวิธีที่สบายที่สุดคือไฟล์ .env ที่ goose อ่านให้เองอัตโนมัติ

# .env ที่รากโปรเจกต์ — goose อ่านให้เอง (flag -env ค่า default คือ .env)
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:secret@localhost:55433/shopdb?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations

⚠️ ถ้าไม่มี .env แล้วพิมพ์ goose up เฉย ๆ มันไม่บอกว่าคุณขาดอะไร — มันพ่นหน้า usage ทั้งหน้าออกมาแทน โดยไม่มีคำว่า error สักคำ (exit 1)

Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND

or

Set environment key
GOOSE_DRIVER=DRIVER
GOOSE_DBSTRING=DBSTRING
GOOSE_MIGRATION_DIR=MIGRATION_DIR
(EXIT=1)

error อีกสามแบบที่หน้าตาไม่เหมือนกันเลย แยกให้ออกจะประหยัดเวลามาก:

# ต่อ DB ได้ แต่ลืมบอกโฟลเดอร์ (ค่า default ของ -dir คือ "." ไม่ใช่ "./migrations")
2026/08/18 19:52:49 goose run: no migration files found

# -dir ชี้ไปที่ที่ไม่มีอยู่จริง
2026/08/18 19:52:49 goose run: ./nope directory does not exist

# dbstring ผิด port — อ่านให้ดี ข้อความยาว 5 บรรทัด ไม่ใช่ 3
2026/08/18 19:52:49 goose run: failed to connect to `user=postgres database=shopdb`:
	[::1]:59999 (localhost): dial error: dial tcp [::1]:59999: connect: connection refused
	127.0.0.1:59999 (localhost): dial error: dial tcp 127.0.0.1:59999: connect: connection refused; failed to connect to `user=postgres database=shopdb`:
	[::1]:59999 (localhost): dial error: dial tcp [::1]:59999: connect: connection refused
	127.0.0.1:59999 (localhost): dial error: dial tcp 127.0.0.1:59999: connect: connection refused

ก้อนที่ซ้ำคือทั้งก้อน ไม่ใช่แค่สอง address — สังเกต ; ตรงกลางที่เชื่อมข้อความ failed to connect to … สองอันเข้าด้วยกัน
สองบรรทัดในแต่ละก้อนคือ pgx (ไลบรารีที่ goose ใช้ต่อกับ Postgres) ลองทั้ง [::1] (IPv6) และ 127.0.0.1 (IPv4) ที่ localhost ชี้ไป · ส่วนที่ก้อนทั้งก้อนโผล่สองรอบ คือ pgx ลองต่อรอบที่สองด้วย fallback config หลังรอบแรกล้ม
ลำดับ IPv6/IPv4 สลับกันได้ในแต่ละรอบ (ผมรันซ้ำแล้วเจอทั้งสองแบบ) อย่าใช้ลำดับเป็นตัวชี้ว่าอะไรผิด — ให้ดูที่ connection refused กับเลข port

พอมี .env แล้วก็สร้างไฟล์แรกได้:

$ goose create create_products_table sql
2026/08/18 19:50:33 Created new file: migrations/20260818125033_create_products_table.sql

🕐 ทำไมเลขในชื่อไฟล์ไม่ตรงกับนาฬิกาบนจอ?
ตอนรันคำสั่งนี้นาฬิกาเครื่องคือ 19:50 (เวลาไทย) แต่ชื่อไฟล์เขียนว่า ...125033 คือ 12:50:33 UTC
goose ตั้งชื่อด้วย timestamp UTC รูปแบบ YYYYMMDDHHMMSS เสมอ เพื่อให้ไฟล์ของคนที่อยู่คนละโซนเวลาเรียงลำดับถูกต้อง เมื่อ merge เข้ามาในโฟลเดอร์เดียวกัน
ส่วนตัวเลข 2026/08/18 19:50:33 ที่นำหน้าทุกบรรทัด คือ prefix ของ log ซึ่งใช้เวลาเครื่อง — บรรทัดเดียวมีสองโซนเวลาปนกัน อย่าเพิ่งคิดว่าตัวเองอ่านผิด

ถ้าทีมเล็กและอยากได้เลขอ่านง่าย มี flag -s (sequential) ให้:

$ goose -s create init sql
2026/08/18 19:53:42 Created new file: migrations/00001_init.sql
$ goose -s create add_users sql
2026/08/18 19:53:42 Created new file: migrations/00002_add_users.sql

อ่านง่ายกว่าจริง แต่แลกมาด้วยความเสี่ยง: สองคนสร้างไฟล์พร้อมกันในคนละ branch จะได้เลขเดียวกัน แล้วชน conflict ตอน merge — ทีมใหญ่จึงใช้ timestamp

5 · โครงไฟล์ migration — annotation ที่ต้องพิมพ์เอง

ไฟล์ที่ goose create สร้างให้ มีแค่นี้จริง ๆ:

-- +goose Up
SELECT 'up SQL query';

-- +goose Down
SELECT 'down SQL query';

⚠️ ไม่มี StatementBegin / StatementEnd มาให้ในไฟล์ — สไลด์หลายชุดเขียนเหมือนว่าเป็นของที่มาพร้อม template ความจริงคือมี annotation แค่ 2 ตัว (Up กับ Down) ที่เหลือต้องพิมพ์เองเฉพาะตอนที่ต้องใช้

ทีนี้มาดูว่าแต่ละ annotation ทำอะไร:

annotationหน้าที่ต้องมีไหม
-- +goose UpSQL ต่อจากนี้คือส่วนที่รันตอน goose upต้องมี ไม่งั้น parse ไม่ผ่าน
-- +goose DownSQL ที่รันตอน goose down เพื่อย้อนกลับควรมีทุกไฟล์ (ดูกับดัก 3 ในหัวข้อ 9)
-- +goose StatementBegin / StatementEndบอก goose ว่า block นี้เป็น statement เดียว ห้ามตัดที่ ;เฉพาะ block ที่มี ; อยู่ข้างใน
-- +goose NO TRANSACTIONไม่ต้องห่อไฟล์นี้ด้วย transaction (มัดทุกคำสั่งเป็นชุดเดียว · เต็ม ๆ ที่หัวข้อโบนัสท้ายบท)เฉพาะคำสั่งที่ Postgres ห้ามอยู่ใน transaction

ไฟล์แรกจริง ๆ ของโปรเจกต์เรา:

-- 20260818125033_create_products_table.sql
-- +goose Up
CREATE TABLE products (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    price      NUMERIC(10,2) NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE products;

สังเกตว่า ทุกอย่างที่ Up ทำ Down ต้องมีคำสั่งย้อนคู่กัน — สร้างตารางคู่กับลบตาราง เพิ่มคอลัมน์คู่กับลบคอลัมน์ สร้าง index คู่กับลบ index

เมื่อไหร่ที่ต้องใช้ StatementBegin

ผมเขียน trigger ที่อัปเดต updated_at อัตโนมัติแบบตรงไปตรงมา — SQL ชุดนี้ copy ไปวางใน psql รันผ่านฉลุย แต่ goose up พัง:

2026/08/18 19:51:38 goose run: ERROR 20260818125138_add_updated_at_trigger.sql: failed to run SQL migration:
failed to execute SQL query "CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$\nBEGIN\n    NEW.updated_at = now();":
ERROR: unterminated dollar-quoted string at or near "$$
BEGIN
    NEW.updated_at = now();" (SQLSTATE 42601)

อ่าน error ให้ดี — SQL ที่ goose ส่งไปที่ DB จบลงที่ NEW.updated_at = now(); พอดี
เพราะ goose แบ่ง statement ด้วยการมอง ; แบบง่าย ๆ มันไม่รู้จัก dollar-quoted string ($$) ของ Postgres เลยตัด function ขาดครึ่งกลางตัว

-- 20260818125138_add_updated_at_trigger.sql
-- +goose Up
ALTER TABLE products ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now();

-- +goose StatementBegin      ← ครอบเฉพาะ block ที่มี ; อยู่ข้างใน
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd

CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- +goose Down
DROP TRIGGER trg_products_updated_at ON products;
DROP FUNCTION set_updated_at();
ALTER TABLE products DROP COLUMN updated_at;
2026/08/18 19:51:54 OK   20260818125138_add_updated_at_trigger.sql (3.77ms)
2026/08/18 19:51:54 goose: successfully migrated database to version: 20260818125138

ครอบแค่ block ที่จำเป็นพอ — ALTER TABLE กับ CREATE TRIGGER ในไฟล์เดียวกัน ไม่ต้องครอบเลย เพราะไม่มี ; ซ่อนอยู่ข้างใน
ครอบทุก statement ก็ไม่ผิด แต่ไฟล์จะรกเปล่า ๆ
กฎง่าย ๆ: เห็น $$ เมื่อไหร่ (function · DO block) ครอบเมื่อนั้น

6 · up · down · status · version

สมมติมี 3 ไฟล์ในโฟลเดอร์ migrations/ แล้วยังไม่เคยรันอะไรเลย:

$ goose status
2026/08/18 19:50:50     Applied At                  Migration
2026/08/18 19:50:50     =======================================
2026/08/18 19:50:50     Pending                  -- 20260818125033_create_products_table.sql
2026/08/18 19:50:50     Pending                  -- 20260818125034_add_stock_to_products.sql
2026/08/18 19:50:50     Pending                  -- 20260818125035_add_index_products_name.sql

$ goose version
2026/08/18 19:50:50 goose: version 0

⚠️ goose status ไม่ใช่คำสั่งอ่านอย่างเดียว — มันเขียน DB
แค่รัน status คำสั่งเดียว goose ก็สร้างตาราง goose_db_version และ insert แถวแรกให้แล้ว ทั้งที่ยังไม่ได้ up อะไรเลย

              List of relations
 Schema |       Name       | Type  |  Owner
--------+------------------+-------+----------
 public | goose_db_version | table | postgres
(1 row)

 id | version_id | is_applied |           tstamp
----+------------+------------+----------------------------
  1 |          0 | t          | 2026-08-18 12:50:50.954417
(1 row)

ถ้าเผลอชี้ GOOSE_DBSTRING ไปที่ production แล้วรัน status เพื่อ "แค่ดูเฉย ๆ" ก็มีตารางโผล่ขึ้นมาแล้วเรียบร้อย

$ goose up
2026/08/18 19:51:01 OK   20260818125033_create_products_table.sql (17.69ms)
2026/08/18 19:51:01 OK   20260818125034_add_stock_to_products.sql (5.54ms)
2026/08/18 19:51:01 OK   20260818125035_add_index_products_name.sql (5.95ms)
2026/08/18 19:51:01 goose: successfully migrated database to version: 20260818125035

$ goose version
2026/08/18 19:51:01 goose: version 20260818125035

$ goose status
2026/08/18 19:51:01     Applied At                  Migration
2026/08/18 19:51:01     =======================================
2026/08/18 19:51:01     Tue Aug 18 12:51:01 2026 -- 20260818125033_create_products_table.sql
2026/08/18 19:51:01     Tue Aug 18 12:51:01 2026 -- 20260818125034_add_stock_to_products.sql
2026/08/18 19:51:01     Tue Aug 18 12:51:01 2026 -- 20260818125035_add_index_products_name.sql

up รันเรียงตามเลข version จากน้อยไปมาก ปิดท้ายด้วยบรรทัดสรุป version ล่าสุด
สังเกตอีกครั้ง: prefix ซ้ายมือคือ 19:51 (เวลาไทย) แต่คอลัมน์ Applied At คือ 12:51 (UTC) — บรรทัดเดียวสองโซนเวลา

goose down ย้อนกลับ ทีละหนึ่งไฟล์ จากบนสุดลงมา:

$ goose down
2026/08/18 19:51:19 OK   20260818125035_add_index_products_name.sql (11.81ms)
-- ก่อน down
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_name" btree (name)

-- หลัง down — index หายไปจริง
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

นอกจาก 4 คำสั่งหลัก ยังมีคำสั่งที่ใช้บ่อยพอควรอีกชุด (ทั้งหมดรันจริงแล้ว):

คำสั่งทำอะไร
goose up-by-oneขึ้นทีละไฟล์ ใช้ตอนอยากดูผลทีละขั้น
goose up-to <version>ขึ้นถึงเลขที่ระบุแล้วหยุด
goose down-to <version>ย้อนลงมาถึงเลขที่ระบุ
goose redodown แล้ว up ไฟล์ล่าสุดใหม่ — ใช้ตอนกำลังเขียนไฟล์นั้นอยู่
goose resetย้อนหมดทุกไฟล์ เรียงจากใหม่ไปเก่า
goose validateตรวจ syntax/annotation ของไฟล์ โดยไม่แตะ DB — เอาไปใส่ CI ได้

🙃 goose redo พิมพ์ชื่อไฟล์เดิมสองบรรทัดติดกัน โดยไม่บอกว่าบรรทัดไหน down บรรทัดไหน up

2026/08/18 19:55:27 OK   20260818125100_add_sku_to_products.sql (2.67ms)
2026/08/18 19:55:27 OK   20260818125100_add_sku_to_products.sql (2.14ms)

บรรทัดแรกคือ down บรรทัดสองคือ up · อ่านเผิน ๆ จะนึกว่ารันซ้ำผิด

7 · เปิดตาราง goose_db_version — จุดที่ทำให้เข้าใจทั้งบท

ทุกอย่างในบทนี้ตัดสินด้วยตารางเดียว มาดูกันว่ามันเก็บอะไรบ้าง:

$ psql -c '\d goose_db_version'

                                  Table "public.goose_db_version"
   Column   |            Type             | Collation | Nullable |             Default
------------+-----------------------------+-----------+----------+----------------------------------
 id         | integer                     |           | not null | generated by default as identity
 version_id | bigint                      |           | not null |
 is_applied | boolean                     |           | not null |
 tstamp     | timestamp without time zone |           | not null | now()
Indexes:
    "goose_db_version_pkey" PRIMARY KEY, btree (id)
🔑 อ่านตารางนี้แล้วจะเดาพฤติกรรม goose ได้ทุกกรณี

มีแค่ 4 คอลัมน์ — และสิ่งที่ไม่มี สำคัญกว่าสิ่งที่มี
❌ ไม่มี checksum ของไฟล์ (checksum = ตัวเลขสั้น ๆ ที่คำนวณจากเนื้อไฟล์ ถ้าเนื้อไฟล์เปลี่ยนแม้แต่ตัวอักษรเดียว ตัวเลขนี้จะเปลี่ยนตาม เครื่องมือบางตัวเก็บค่านี้ไว้ตอนรัน เพื่อจับได้ทันทีว่าไฟล์ถูกแก้ทีหลัง)
❌ ไม่มี SQL ที่รันไป
❌ ไม่มีชื่อไฟล์ด้วยซ้ำ — เก็บแค่ตัวเลขหน้าไฟล์

เท่ากับว่าคำถามเดียวที่ goose ตอบได้คือ "เลข version นี้เคยรันไปแล้วหรือยัง"
มันไม่มีทางรู้ว่าไฟล์ที่เคยรันไปนั้น ตอนนี้ หน้าตายังเหมือนเดิมไหม

🧾 เทียบให้เห็นภาพ — สมุดจดว่าจ่ายบิลเดือนไหนไปแล้ว

หลายบ้านมีสมุดเล่มเล็ก ๆ ไว้จดว่าจ่ายค่าน้ำค่าไฟเดือนไหนไปแล้วบ้าง
ในสมุดมีแค่บรรทัดสั้น ๆ ว่า “ม.ค. จ่ายแล้ว” · “ก.พ. จ่ายแล้ว” · “มี.ค. จ่ายแล้ว”
ไม่ได้จดยอดเงิน ไม่ได้แนบสำเนาใบบิล จดแค่ว่าเดือนนั้นผ่านไปแล้ว

goose_db_version คือสมุดเล่มนั้น · version_id คือ “เดือน” · tstamp คือวันที่จด
คำถามเดียวที่สมุดตอบได้จึงเป็น “เดือนนี้จ่ายรึยัง” ไม่ใช่ “จ่ายไปเท่าไหร่”
ทีนี้ลองคิดตาม: ถ้ามีคนกลับไปแก้ยอดในใบบิลเดือน ม.ค. ทีหลัง สมุดก็ยังเขียนว่า “จ่ายแล้ว” เหมือนเดิม ไม่มีอะไรในสมุดที่จะทำให้รู้เลยว่าใบบิลถูกแก้
และที่หนักกว่า “บันทึกไม่ตรงความจริง” คือ บ้านหลังใหม่ที่เพิ่งเริ่มไล่จ่ายตั้งแต่ใบแรก จะจ่ายตามใบที่ถูกแก้แล้ว ส่วนบ้านหลังเก่าจ่ายตามใบเดิมไปแล้ว — สองหลังจึงต่างกันถาวร โดยสมุดทั้งสองเล่มเขียนว่า “จ่ายแล้ว” เหมือนกันเป๊ะ
นั่นคือกับดักข้อ 1 ในหัวข้อ 9 ทั้งดุ้น — ยังไม่ต้องรีบเปิดไปดู เดี๋ยวไปด้วยกัน

ที่ไม่เหมือนกัน: ใบบิลจริงยังขอสำเนาย้อนดูที่การไฟฟ้าได้ แต่ goose ไม่ได้เก็บสำเนา SQL ไว้ที่ไหนเลย เหลือแค่ไฟล์ใน git ของคุณอย่างเดียว
และสมุดค่าน้ำค่าไฟมีแต่จดเพิ่ม ส่วนสมุดของ goose ลบบรรทัดทิ้งได้ด้วย — เดี๋ยวท้ายหัวข้อนี้จะได้เห็นว่าลบตอนไหน

หลัง goose up 3 ไฟล์ ข้อมูลในตารางเป็นแบบนี้:

 id |   version_id   | is_applied |           tstamp
----+----------------+------------+----------------------------
  1 |              0 | t          | 2026-08-18 12:50:50.954417
  2 | 20260818125033 | t          | 2026-08-18 12:51:01.789388
  3 | 20260818125034 | t          | 2026-08-18 12:51:01.80727
  4 | 20260818125035 | t          | 2026-08-18 12:51:01.812882
(4 rows)

version_id คือตัวเลขหน้าชื่อไฟล์ตรง ๆ · แถว version_id = 0 คือแถวเริ่มต้นที่ goose ใส่ให้ตอนสร้างตาราง

ทีนี้คำถามสำคัญ — goose down ทำอะไรกับตารางนี้?

$ goose down
2026/08/18 19:51:19 OK   20260818125035_add_index_products_name.sql (11.81ms)

 id |   version_id   | is_applied |           tstamp
----+----------------+------------+----------------------------
  1 |              0 | t          | 2026-08-18 12:50:50.954417
  2 | 20260818125033 | t          | 2026-08-18 12:51:01.789388
  3 | 20260818125034 | t          | 2026-08-18 12:51:01.80727
(3 rows)

แถวหายไปเลย — เป็น DELETE ไม่ใช่การ set is_applied = false
ชื่อคอลัมน์ is_applied ชวนให้เข้าใจผิดมาก ความจริงคือทุกแถวเป็น t เสมอ ไม่มีวันเจอ f
ผลข้างเคียงที่ต้องรู้ไว้: หลังผ่าน down/up หลายรอบ id จะกระโดด เช่น 1, 2, 5, 6 เพราะ identity column (คอลัมน์ที่ DB แจกเลขวิ่งให้เองทีละหนึ่ง) ไม่ย้อนเลขกลับ — ไม่ได้มีอะไรพัง

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้ใช้ได้กับเครื่องมือ migration ทุกยี่ห้อ ไม่ใช่แค่ goose

version table (บางคนเรียก migration table)
“สมุดจด” ที่เครื่องมือ migration สร้างไว้ใน DB ของคุณเอง · ของ goose ชื่อ goose_db_version · ถามว่า “version table บอกว่าตอนนี้อยู่ version ไหน” จะตรงกว่าถามว่า “DB ตัวนี้อัปเดตรึยัง”
apply · applied · pending
“รันไปแล้ว” กับ “ยังไม่ได้รัน” — สองคำนี้คือสิ่งที่ goose status พิมพ์ออกมาตรง ๆ · ประโยคที่ใช้จริง: “ไฟล์นี้ apply ไปแล้วหรือยัง” และ “บน prod ยังมี migration ค้าง pending อยู่ 2 ไฟล์”
checksum
“ลายนิ้วมือของไฟล์” — ตัวเลขที่คำนวณจากเนื้อไฟล์ เนื้อเปลี่ยนตัวอักษรเดียวเลขก็เปลี่ยน · ประโยคเดียวที่ทำให้ senior รู้ว่าคุณเข้าใจบทนี้แล้วจริง: “goose ไม่เก็บ checksum เลยจับไม่ได้ว่าไฟล์ถูกแก้ทีหลัง”
source of truth
“ตัวจริงที่ทุกคนยึดเป็นหลัก” · ในเรื่อง schema ตัวจริงคือไฟล์ใน git ไม่ใช่ DB ในเครื่องใครคนใดคนหนึ่ง · ประโยคที่เอาไปใช้ตอนตกลงกับทีม: “ขอให้ goose เป็น source of truth ของ schema นะ ไม่ใช่ AutoMigrate”

8 · seed ที่รันซ้ำแล้วข้อมูลไม่ซ้ำ

กลับมาที่หัวข้อ 1 — ผมเขียน seed master data เป็น migration ธรรมดา ไม่มี ON CONFLICT ไม่มีอะไรกันซ้ำเลย:

-- 20260818125201_seed_categories.sql
-- +goose Up
INSERT INTO categories (code, name) VALUES
    ('ELEC', 'Electronics'),
    ('BOOK', 'Books'),
    ('FOOD', 'Food & Drink');

-- +goose Down
DELETE FROM categories WHERE code IN ('ELEC','BOOK','FOOD');
$ goose up   # รอบ 1
2026/08/18 19:52:09 OK   20260818125159_create_categories_table.sql (16.95ms)
2026/08/18 19:52:09 OK   20260818125201_seed_categories.sql (2.68ms)
2026/08/18 19:52:09 goose: successfully migrated database to version: 20260818125201

$ goose up   # รอบ 2
2026/08/18 19:52:14 goose: no migrations to run. current version: 20260818125201

$ goose up   # รอบ 3
2026/08/18 19:52:14 goose: no migrations to run. current version: 20260818125201

$ psql -c 'SELECT count(*) FROM categories;'
 count
-------
     3
(1 row)

3 แถวเท่าเดิม — แต่เหตุผลไม่ใช่ที่คนส่วนใหญ่คิด
ไม่ใช่เพราะ SQL ฉลาด ไม่ใช่เพราะ UNIQUE constraint ดักไว้ (ถ้าเป็นแบบนั้นต้องมี error โผล่)
แต่เป็นเพราะ goose เห็นว่า version_id = 20260818125201 มีอยู่ในตารางแล้ว จึงไม่เปิดไฟล์นั้นอ่านด้วยซ้ำ

นี่คือคำตอบของหัวข้อ 1: สิ่งที่กันข้อมูลซ้ำคือ goose_db_version ไม่ใช่ตัว SQL
ซึ่งแปลว่าถ้าคุณเอา seed ไปวางในสคริปต์ที่รันทุกครั้งตอนแอป start (แบบเดียวกับ AutoMigrate) คุณต้องเขียน ON CONFLICT DO NOTHING เองทุกที่ ไม่งั้นข้อมูลบานทันที

9 · กับดัก 3 ข้อที่ทำ production พังจริง

กับดัก 1 — แก้ไฟล์ migration ที่ up ไปแล้ว

# สถานการณ์: seed_categories.sql ถูก up ไปแล้วเมื่อวาน (มี 3 แถวใน DB)
# วันนี้ PM ขอเพิ่ม category ใหม่ คุณเลยเปิดไฟล์เดิมมาเติมบรรทัด

-- +goose Up
INSERT INTO categories (code, name) VALUES
    ('ELEC', 'Electronics'),
    ('BOOK', 'Books'),
    ('FOOD', 'Food & Drink'),
    ('TOYS', 'Toys');        ← เพิ่มบรรทัดนี้เข้าไป

$ goose up
$ goose validate
$ psql -c 'SELECT * FROM categories;'

รัน 3 คำสั่งนี้แล้วเห็นอะไร? มี error ไหม · validate ฟ้องไหม · ในตารางมีกี่แถว
และถ้าเพื่อนอีกคนที่ DB ยังว่างอยู่ pull โค้ดชุดเดียวกันนี้ไปรัน goose up เขาจะได้กี่แถว?

$ goose up
2026/08/18 19:52:24 goose: no migrations to run. current version: 20260818125201
EXIT=0

$ goose validate
EXIT=0

ไม่มี error · ไม่มี warning · exit 0 ทั้งคู่ — แม้แต่คำสั่งที่ชื่อ validate ก็เงียบสนิท

=== เครื่องคุณ (DB เดิม) ===
 id | code |     name
----+------+--------------
  1 | ELEC | Electronics
  2 | BOOK | Books
  3 | FOOD | Food & Drink
(3 rows)

=== เครื่องเพื่อน (DB เปล่า รันไฟล์ชุดเดียวกัน) ===
2026/08/18 19:52:34 OK   20260818125159_create_categories_table.sql (4.01ms)
2026/08/18 19:52:34 OK   20260818125201_seed_categories.sql (1.3ms)

 id | code |     name
----+------+--------------
  1 | ELEC | Electronics
  2 | BOOK | Books
  3 | FOOD | Food & Drink
  4 | TOYS | Toys
(4 rows)

โค้ดชุดเดียวกัน · คำสั่งเดียวกัน · ได้ DB คนละแบบ
เครื่องคุณ 3 แถว เครื่องเพื่อน 4 แถว และไม่มีเครื่องมือตัวไหนบอกคุณเลย

ย้อนกลับไปดูหัวข้อ 7: goose_db_version เก็บแค่ version_id ไม่มี checksum ของไฟล์ goose จึงเทียบได้แค่ "เลขนี้เคยรันยัง" ไม่มีทางรู้ว่าเนื้อไฟล์เปลี่ยนไปแล้ว
ส่วน goose validate ตรวจแค่ syntax กับ annotation ในไฟล์ — มันไม่ได้เทียบกับ DB เลย

กฎเหล็ก: ไฟล์ที่ commit แล้วและ apply ไปแล้ว ห้ามแก้เด็ดขาด ต้องสร้างไฟล์ใหม่เสมอ
(ถ้าเผลอแก้แล้วแต่ยังไม่ push และเป็นเครื่องตัวเองเครื่องเดียว → goose down ก่อน แล้วค่อยแก้ แล้ว up ใหม่)
เครื่องมืออื่นอย่าง Flyway/Liquibase เก็บ checksum ไว้ จึง fail ให้เห็นทันที — goose ไม่มี ต้องอาศัยวินัยของทีมล้วน ๆ

กับดัก 2 — Down ที่ย้อน schema ได้ แต่ย้อนข้อมูลไม่ได้

ไฟล์นี้ดูสมมาตรสมบูรณ์แบบ ไม่มีอะไรน่าสงสัย:

-- +goose Up
ALTER TABLE products ADD COLUMN stock INTEGER NOT NULL DEFAULT 0;

-- +goose Down
ALTER TABLE products DROP COLUMN stock;
=== ข้อมูลก่อน down ===
 id |   name   | stock
----+----------+-------
  1 | Keyboard |    42
  2 | Mouse    |     7
(2 rows)

$ goose down
2026/08/18 19:51:29 OK   20260818125034_add_stock_to_products.sql (7.8ms)

$ goose up
2026/08/18 19:51:29 OK   20260818125034_add_stock_to_products.sql (2.59ms)
2026/08/18 19:51:29 OK   20260818125035_add_index_products_name.sql (2.44ms)
2026/08/18 19:51:29 goose: successfully migrated database to version: 20260818125035

=== ข้อมูลหลัง up ===
 id |   name   | stock
----+----------+-------
  1 | Keyboard |     0
  2 | Mouse    |     0
(2 rows)

⚠️ goose รายงาน OK ทุกบรรทัด ไม่มี error สักตัว — แต่ 42 กับ 7 หายไปตลอดกาล
goose down ไม่ใช่ undo และไม่ใช่ snapshot — มันคือ "รัน SQL ที่อยู่ใน block Down" เท่านั้น
คุณเขียนว่า DROP COLUMN มันก็ DROP COLUMN ซึ่งทำลายข้อมูลถาวร พอ up ใหม่ ADD COLUMN ... DEFAULT 0 ก็เติมศูนย์ให้ทุกแถว
สรุป: rollback ย้อนได้แค่ schema ไม่เคยย้อน ข้อมูล

🚪 เทียบให้เห็นภาพ — รื้อตู้บิลท์อินแล้วสั่งทำใหม่

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

Down ที่เขียนว่า DROP COLUMN คือ “รื้อตู้” · Up ที่เขียนว่า ADD COLUMN … DEFAULT 0 คือ “ทำตู้ใหม่แล้ววางของตั้งต้นให้เท่ากันทุกช่อง”
ช่างทำถูกทุกขั้นตอน ใบสั่งงานก็เขียนไว้แบบนั้นจริง ๆ — เลยไม่มีใครผิด และไม่มี error ให้เห็นสักบรรทัด

ที่ไม่เหมือนกัน: รื้อตู้จริง เสื้อผ้ายังกองอยู่บนพื้นให้เก็บกลับเข้าไปได้ แต่ DROP COLUMN ลบข้อมูลทิ้งไปพร้อมคอลัมน์ทันที ไม่มีกองไหนให้เก็บ และใน DB ไม่มีถังขยะให้กดกู้
พอ migration ไฟล์นั้นจบไปแล้ว ทางกลับมามีทางเดียวคือ restore จาก backup ที่ทำไว้ก่อนหน้า (นี่คือเหตุผลที่ตารางข้างล่างเขียนว่า “หลัง backup” ค่อย DROP)
ถ้าอยากให้ของกลับมาโดยไม่ต้องพึ่ง backup ต้องสั่งไว้เองใน Down ว่าให้ย้ายของไปเก็บที่อื่นก่อนค่อยรื้อ (ดูตารางข้างล่าง แถวสุดท้าย)

ในทางปฏิบัติจึงมีกฎ 3 ข้อ:

สถานการณ์ทำยังไง
อยากย้อน migration บน productionอย่าใช้ down — เขียนไฟล์ migration ใหม่ที่แก้ไปข้างหน้า (forward-only)
จะลบคอลัมน์จริง ๆแยกเป็น 2 deploy: รอบแรกเลิกใช้คอลัมน์นั้นในโค้ด · รอบสอง (หลัง backup) ค่อย DROP
ต้องย้อนได้จริงแบบมีข้อมูลDown ต้อง copy ข้อมูลไปตารางสำรองก่อน DROP
✅ แล้ว down มีไว้ทำอะไร

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

🗣 ศัพท์ที่เพิ่งเรียนไป

ห้าคำนี้คือคำที่ใช้ตอนถกกันว่าจะขึ้น production ยังไง — เป็นบทสนทนาที่จะเจอบ่อยมาก

transaction
“มัดหลายคำสั่งเป็นชุดเดียว” — ถ้าพังคำสั่งใดคำสั่งหนึ่ง ถือว่าทั้งชุดไม่เคยเกิดขึ้นเลย · goose ห่อทั้งไฟล์ไว้ใน transaction เดียว (ของจริงอยู่หัวข้อโบนัสถัดจากนี้)
rollback
“ย้อนกลับ” · แต่ตอนนี้คุณรู้แล้วว่ามันย้อนแค่ schema ไม่ย้อนข้อมูล · คำถามที่ควรถามจึงไม่ใช่ “ย้อนได้ไหม” แต่เป็น “ไฟล์นี้ rollback แล้วข้อมูลหายไหม”
ระวัง คำนี้มีสองความหมายในบทเดียวกัน: rollback ระดับ migration = เราสั่ง goose down เอง · rollback ระดับ transaction = Postgres ย้อนคืนให้เองเมื่อคำสั่งพังกลางชุด (หัวข้อโบนัสถัดจากนี้) · เวลาคุยให้ระบุไปเลยว่าหมายถึงอันไหน
forward-only
“แก้ไปข้างหน้าอย่างเดียว ไม่ย้อน” — เจอปัญหาบน production ก็เขียนไฟล์ใหม่แก้ทับ ไม่สั่ง down · พูดสั้น ๆ ว่า “ทีมเราทำ forward-only นะ” senior จะเข้าใจนโยบายทั้งหมดในคำเดียว
destructive migration
migration ที่ “ทำข้อมูลหายถาวร” เช่น DROP COLUMN · DROP TABLE · ตอนขอรีวิวให้บอกไปเลยว่า “ไฟล์นี้ destructive ช่วยดูให้หน่อย” คนรีวิวจะได้ตั้งใจอ่านเป็นพิเศษ
expand and contract (อีกชื่อคือ parallel change)
expand = รอบแรกเพิ่มของใหม่เข้าไปก่อน โดยยังเก็บของเก่าไว้ ให้ระบบใช้ได้ทั้งสองแบบพร้อมกัน · contract = รอบสุดท้ายค่อยลบของเก่าทิ้งเมื่อไม่มีใครเรียกแล้ว
ตัวอย่างเต็มรูป: จะเปลี่ยน price เป็น price_cents → เพิ่ม price_cents ก่อน · backfill ค่าเดิมเข้าไป · ให้โค้ดเขียนลงทั้งสองคอลัมน์ไปสักพัก · แล้วรอบสุดท้ายค่อย DROP COLUMN price
ส่วนแถวกลางของตารางข้างบน (เลิกใช้ในโค้ดก่อน แล้วค่อย DROP) คือครึ่ง contract เท่านั้น — ไม่มีขั้น expand เพราะไม่ได้เพิ่มของใหม่มาแทน
ถามว่า “ลบคอลัมน์นี้ทำเป็น expand-contract ดีไหม” ดีกว่าถามว่า “ลบเลยได้ไหม” เยอะ · และเลี่ยงคำว่า “two-phase” ในบริบท DB เพราะไปชนกับ two-phase commit (2PC) ซึ่งเป็นคนละเรื่อง

กับดัก 3 — ลืมเขียน block Down

ถ้าไฟล์มีแต่ -- +goose Up แล้วสั่ง goose down คุณอาจคิดว่ามันจะฟ้อง — เปล่าเลย:

$ goose down
2026/08/18 19:53:29 EMPTY 20260818130000_no_annotation.sql (1.71ms)
EXIT=0

$ psql -c '\dt'
 public | goose_db_version | table | postgres
 public | t1               | table | postgres      ← ตารางยังอยู่!
(2 rows)

$ psql -c 'SELECT * FROM goose_db_version;'
 id | version_id | is_applied |           tstamp
----+------------+------------+----------------------------
  1 |          0 | t          | 2026-08-18 12:53:29.160615
(1 row)                                          ← แต่แถว version หายไปแล้ว

ตอนนี้ DB จริงกับ version table ไม่ตรงกัน — ตาราง t1 มีอยู่ แต่ goose คิดว่ายังไม่เคยสร้าง
พอสั่ง up เพื่อกู้สถานการณ์:

2026/08/18 19:53:36 goose run: ERROR 20260818130000_no_annotation.sql: failed to run SQL migration:
failed to execute SQL query "CREATE TABLE t1 (id int);": ERROR: relation "t1" already exists (SQLSTATE 42P07)
EXIT=1

ติดหล่มสมบูรณ์: up ก็พัง down ก็ไม่ช่วย ทางออกเดียวคือเข้าไป DROP TABLE t1; ด้วยมือ แล้วค่อย goose up ใหม่
→ เขียน Down เสมอ แม้จะเป็นแค่ DROP TABLE บรรทัดเดียว

โบนัส — ข่าวดีข้อเดียวของบทนี้

ถ้า migration พังกลางไฟล์ ไม่มีอะไรค้างครึ่ง ๆ กลาง ๆ
เพราะ goose ห่อทั้งไฟล์ไว้ใน transaction เดียว — คือมัดทุกคำสั่งในไฟล์เป็นชุดเดียว ถ้าพังคำสั่งใดคำสั่งหนึ่ง ให้ถือว่าทั้งชุดไม่เคยเกิดขึ้น
ในชุดนั้นมีทั้งคำสั่งที่เปลี่ยนโครงสร้าง (เรียกรวม ๆ ว่า DDL = คำสั่งกลุ่ม CREATE / ALTER / DROP) และการ INSERT ลง goose_db_version
แปลว่าถ้าพังกลางทาง Postgres จะย้อนคืนให้หมดทั้งชุด เหมือนไม่เคยรันเลย
ตอนที่ไฟล์ trigger พังเพราะลืม StatementBegin ผมเช็คทันทีหลังจากนั้น:

$ goose version
2026/08/18 19:51:44 goose: version 20260818125035     ← ยังอยู่ที่ไฟล์ก่อนหน้า

$ psql -c '\df'
                       List of functions
 Schema | Name | Result data type | Argument data types | Type
--------+------+------------------+---------------------+------
(0 rows)                                              ← function ไม่ถูกสร้างเลย

แก้ไฟล์แล้วรัน goose up ใหม่ได้เลย ไม่ต้องตามเก็บกวาดอะไร
แต่ข้อนี้เป็นจริงเฉพาะ Postgres (และ MySQL InnoDB) ที่ DDL อยู่ใน transaction ได้ — MySQL รุ่นที่ DDL auto-commit ห้ามเหมารวม

🔧 ข้อยกเว้น: CREATE INDEX CONCURRENTLY
คำสั่งนี้มีไว้สร้าง index บนตารางใหญ่โดยไม่ล็อกตาราง แต่ Postgres ห้ามมันอยู่ใน transaction:

2026/08/18 19:55:42 goose run: ERROR 00002_concurrent_index.sql: failed to run SQL migration:
failed to execute SQL query "CREATE INDEX CONCURRENTLY idx_items_sku ON items (sku);":
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001)

ทางแก้คือใส่ -- +goose NO TRANSACTION เป็นบรรทัดแรกของไฟล์ (ก่อน -- +goose Up) → OK 00002_concurrent_index.sql (2.79ms)
แลกมาด้วยว่าไฟล์นั้นไม่มี rollback อัตโนมัติแล้ว พังกลางทางต้องตามเก็บเอง → ใส่ statement เดียวต่อไฟล์เท่านั้น

10 · goose ทำสิ่งที่ AutoMigrate ทำไม่ได้ยังไง

ปิดวงกลับไปที่หัวข้อ 3 — ผมเอา DB ตัวเดียวกันที่ GORM สร้างไว้ มาให้ goose จัดการต่อ แล้วสั่งเปลี่ยนชื่อคอลัมน์ที่ AutoMigrate ทำไม่ได้:

-- 00001_rename_price_to_unit_price.sql
-- +goose Up
ALTER TABLE products RENAME COLUMN price TO unit_price;

-- +goose Down
ALTER TABLE products RENAME COLUMN unit_price TO price;

❌ AutoMigrate เปลี่ยนชื่อ

 id |   name   | price | cost
----+----------+-------+------
  1 | Keyboard |  1200 |
  2 | Mouse    |   590 |

ได้คอลัมน์ใหม่ว่าง ๆ · ของเก่ายังอยู่ · ข้อมูลไม่ตามมา

✅ goose RENAME COLUMN

 id |   name   | unit_price
----+----------+------------
  1 | Keyboard |       1200
  2 | Mouse    |        590

คอลัมน์เดิมเปลี่ยนชื่อ · ข้อมูลติดไปด้วย · down ย้อนชื่อกลับได้

ไม่ใช่เพราะ goose ฉลาดกว่า — แต่เพราะ goose ให้คุณเขียน SQL เอง คุณเป็นคนเดียวที่รู้ว่า cost คือ price ที่เปลี่ยนชื่อ ไม่ใช่คอลัมน์ใหม่ และ goose แค่ทำให้ความรู้นั้นกลายเป็นไฟล์ที่รีวิวได้ · เก็บใน git ได้ · รันเหมือนกันทุกเครื่อง

🤝 ใช้ทั้งคู่พร้อมกันได้ไหม? ได้ และหลายทีมทำแบบนี้: ให้ goose เป็นเจ้าของ schema แต่เพียงผู้เดียว · ใช้ GORM แค่ query ไม่เรียก AutoMigrate เลย
ส่วน struct ของ GORM ก็ยังต้องแก้ให้ตรงกับ schema เอง — ไม่มีใครซิงก์ให้อัตโนมัติ ถ้าไม่ตรงจะรู้ตอน query แล้ว error เท่านั้น (เป็นอีกเหตุผลที่ต้องมี integration test — บทถัดไป)

ทดสอบความเข้าใจ

1. ตาราง goose_db_version เก็บอะไรไว้บ้าง?

เปิดดูจริงมี 4 คอลัมน์: id · version_id · is_applied · tstamp
ไม่มี checksum ไม่มี SQL ไม่มีแม้แต่ชื่อไฟล์ — เก็บแค่ตัวเลขหน้าไฟล์
ข้อ 2 คือสิ่งที่ Flyway/Liquibase ทำ แต่ goose ไม่ทำ → เป็นสาเหตุโดยตรงที่แก้ไฟล์ที่ apply แล้ว goose ตรวจไม่เจอ · ข้อ 1 กับ 4 ไม่มีอะไรมาเก็บได้ในตาราง 4 คอลัมน์

2. แก้ไฟล์ที่ up ไปแล้ว เพิ่ม INSERT อีกหนึ่งบรรทัด แล้วรัน goose up ใหม่?

ผลจริง: goose: no migrations to run. current version: 20260818125201 · EXIT=0
แม้แต่ goose validate ก็ EXIT=0 เพราะมันตรวจแค่ syntax ไม่ได้เทียบกับ DB
ข้อ 2 คือพฤติกรรมของ Flyway ที่มี checksum · ข้อ 1 และ 3 เป็นไปไม่ได้เพราะ goose ไม่เปิดไฟล์นั้นอ่านด้วยซ้ำ เมื่อเห็นว่า version_id อยู่ในตารางแล้ว
อันตรายจริงคือ DB เพื่อนที่ยังว่างจะได้ 4 แถว ส่วนของคุณค้างที่ 3 แถว — แตกกันเงียบ ๆ

3. Up คือ ADD COLUMN stock DEFAULT 0 · Down คือ DROP COLUMN stock — สั่ง down แล้ว up ใหม่ ข้อมูล stock เป็นอะไร?

ก่อน down มี 42 กับ 7 · หลัง down แล้ว up ใหม่ ได้ 0 ทั้งสองแถว และ goose พิมพ์ OK ทุกบรรทัด ไม่มี error เลย
goose down คือการรัน SQL ใน block Down เท่านั้น ไม่ใช่ snapshot ไม่ใช่ undo
ไม่ใช่ NULL เพราะ Up ระบุ NOT NULL DEFAULT 0 → ทุกแถวได้ค่า default
ข้อ 4 ผิดชัดเจน: goose ไม่มีกลไกตรวจว่า SQL ของคุณอันตรายไหม มันแค่รันตามที่เขียน

4. เปลี่ยนชื่อ struct field จาก Price เป็น Cost แล้วรัน AutoMigrate?

ผลจริง: ตารางมีทั้ง price (1200, 590) และ cost (ว่าง) — พิมพ์ AutoMigrate step2 OK ไม่มี error
AutoMigrate เติมสิ่งที่ขาดอย่างเดียว มันไม่มีประวัติให้ดู จึงไม่มีทางรู้ว่า cost คือ price ที่เปลี่ยนชื่อ
ข้อ 4 ผิดเพราะเอกสาร GORM ระบุเองว่า จะไม่ลบคอลัมน์ที่ไม่ได้ใช้ (กันข้อมูลหาย)
สิ่งที่ต้องทำจริงคือ ALTER TABLE products RENAME COLUMN price TO unit_price; ผ่าน goose ซึ่งพิสูจน์แล้วว่าข้อมูลอยู่ครบและ down ย้อนชื่อกลับได้

🛠️ ภารกิจ — ทำ migration ชุดแรกของโปรเจกต์คุณเอง

ต่อยอดจากภารกิจบทเรียน 27 — โปรเจกต์ที่มี internal/repository และ AutoMigrate อยู่
เป้าหมายวันนี้: ย้ายความรับผิดชอบเรื่อง schema จาก GORM มาที่ goose ให้หมด

  1. เปิด PostgreSQL ใน Docker แล้ววาง .env ที่รากโปรเจกต์ (GOOSE_DRIVER · GOOSE_DBSTRING · GOOSE_MIGRATION_DIR)
    ทดสอบว่าตั้งถูกด้วย goose status — ต้องขึ้น Pending ไม่ใช่หน้า usage
  2. สร้าง migration 3 ไฟล์ ด้วย goose create <ชื่อ> sql ทีละไฟล์:
    ① สร้างตาราง ② เพิ่มคอลัมน์ ③ เพิ่ม index
    ทุกไฟล์ต้องมี Down ที่ย้อนได้จริง
    ถ้ายังไม่เคยเขียน SQL 3 แบบนี้ นี่คือหน้าตาของมัน (แก้ชื่อตาราง/คอลัมน์เป็นของโปรเจกต์คุณ):
    -- ① สร้างตาราง
    CREATE TABLE products (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL);
    DROP TABLE products;                              -- คู่ Down
    
    -- ② เพิ่มคอลัมน์
    ALTER TABLE products ADD COLUMN stock INTEGER NOT NULL DEFAULT 0;
    ALTER TABLE products DROP COLUMN stock;           -- คู่ Down
    
    -- ③ เพิ่ม index
    CREATE INDEX idx_products_name ON products (name);
    DROP INDEX idx_products_name;                     -- คู่ Down
    รู้ได้ยังไงว่าทำถูก: goose status ต้องขึ้น Pending ครบ 3 บรรทัด และ goose validate ต้อง exit 0
  3. goose up แล้วเปิด SELECT * FROM goose_db_version; ดู
    มีกี่แถว · เลข version_id ตรงกับอะไร
  4. goose down หนึ่งครั้ง แล้วเปิดตารางเดิมดูอีกที — แถวหายไป หรือ is_applied เปลี่ยนเป็น f?
  5. เขียน seed master data เป็น migration (ไม่ต้องใส่ ON CONFLICT) แล้วรัน goose up 3 รอบติดกัน — นับแถวว่าซ้ำไหม แล้วอธิบายให้ได้ว่าทำไม
  6. ปิด AutoMigrate ในโค้ดแอป ให้เหลือแค่ gorm.Open อย่างเดียว
    รันแอป — ยังทำงานได้ไหม? ถ้าลืมสร้างตารางไหนใน migration จะรู้ตอนไหน?
  7. ท้าทาย 1 — trigger: เขียน migration ที่สร้าง function set_updated_at() พร้อม trigger โดยยังไม่ใส่ StatementBegin รันดูให้เห็น error เต็ม ๆ ก่อน แล้วค่อยใส่ครอบ
  8. ท้าทาย 2 — ทำลาย DB ตัวเองให้ดู: แก้ไฟล์ seed ที่ up ไปแล้ว เพิ่มอีกหนึ่งแถว แล้วรัน goose up · goose validate
    จากนั้นสร้าง database เปล่าตัวใหม่ขึ้นมา (จำลองว่าเป็นเครื่องเพื่อน) แล้วชี้ GOOSE_DBSTRING ไปที่นั่น รัน goose up
    # สร้าง database เปล่าชื่อ shopdb2 ใน container เดิม
    docker exec -it pg-shop psql -U postgres -c 'CREATE DATABASE shopdb2;'
    
    # แก้ .env ให้ชี้ไปที่ตัวใหม่ (เปลี่ยนแค่ชื่อ database ท้าย URL)
    # GOOSE_DBSTRING=postgres://postgres:secret@localhost:55433/shopdb2?sslmode=disable
    
    goose up
    รู้ได้ยังไงว่าทำถูก: SELECT count(*) FROM categories; บน shopdb กับ shopdb2 ต้องได้เลขไม่เท่ากัน ทั้งที่ไฟล์ใน git ชุดเดียวกันเป๊ะ
    เทียบจำนวนแถวสอง DB แล้วเขียนกฎของทีมคุณเองหนึ่งบรรทัดว่าจะกันเรื่องนี้ยังไง
  9. ท้าทาย 3: ลองลบ block -- +goose Down ออกจากไฟล์หนึ่ง แล้วสั่ง goose down — ได้คำว่าอะไร exit เท่าไหร่ แล้วสั่ง goose up ต่อทันที เกิดอะไรขึ้น และจะกู้ยังไง?

แอปยังรันได้ปกติ — ตราบใดที่ schema ตรงกับ struct
เพราะ AutoMigrate ไม่ได้เกี่ยวกับการ query เลย มันแค่พยายามแก้ schema ตอน start

แต่ถ้าลืมสร้างคอลัมน์ไหนใน migration คุณจะไม่รู้ตอน start — คุณจะรู้ตอนมี request แรกวิ่งเข้ามาแล้ว query พังกลาง production
นี่คือข้อแลกเปลี่ยนที่ต้องรับให้ได้: ปิด AutoMigrate = ย้ายจุดที่พังจาก "ตอน deploy" ไปเป็น "ตอนมี traffic"

ทางแก้ไม่ใช่เปิด AutoMigrate กลับ แต่คือ เขียน integration test ที่รันบน schema จริง แล้วยิงทุก query ของ repository ให้ครบ — ถ้า struct ไม่ตรง schema เทสจะแดงตั้งแต่ใน CI ก่อนถึง production · เป็นเนื้อหาของบทถัดไป

💬 ข้อ 3 กับข้อ 4 คือสองข้อที่ผมอยากให้ทำที่สุด — เพราะถ้าคุณเห็นด้วยตาตัวเองว่าตารางนั้นมีแค่ 4 คอลัมน์ และ down คือการลบแถวทิ้ง คุณจะเดาพฤติกรรมของ goose ได้เองทุกกรณีโดยไม่ต้องท่อง รวมถึงกับดักที่บทนี้ยังไม่ได้พูดถึง
มีอะไรที่ลองแล้วผลไม่เหมือนในบท ส่งมาได้เลย — โดยเฉพาะถ้าคุณใช้ MySQL แทน Postgres เพราะเรื่อง transaction ต่างกันมาก
บทถัดไป: ทำให้ Redis เข้ามาอยู่ระหว่างแอปกับ database โดยไม่ทำให้ข้อมูลเพี้ยน

อ้างอิง