การสร้าง table
ทุก table เริ่มต้นชีวิตด้วย statement CREATE TABLE คุณตั้งชื่อ table แล้วเรียง column ในวงเล็บ โดยแต่ละ column มีชื่อและ data type เซิร์ฟเวอร์เก็บนิยามนั้นไว้และบังคับใช้กับทุก row ที่คุณ insert นับจากนั้น การสร้าง table คือ DDL — data definition language — และแทบทุกครั้งเขียนและรันเป็น SQL ธรรมดา ไม่ว่าจะพิมพ์ใน psql หรือป้อนให้เครื่องมือ migration
เราจะสร้าง table products สำหรับร้านค้าเล็ก ๆ และขยายมันไปตามบทเรียน ทุก output ด้านล่างเป็นของจริงจาก PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) แต่ละ fence รันในฐานข้อมูลเปล่า จากบนลงล่าง
รูปร่างพื้นฐาน
หัวข้อที่มีชื่อว่า “รูปร่างพื้นฐาน”ขั้นต่ำคือชื่อ table และ column หนึ่งตัวขึ้นไป แต่ละ column คือชื่อตามด้วย type คั่นด้วยเครื่องหมายจุลภาค ใน psql คำสั่ง \d จะอธิบายผลลัพธ์: column, type, nullability และ default
-- sql/loose-table.sqlCREATE TABLE products ( id integer, name text, price numeric(10, 2));\d productsCREATE TABLE Table "public.products" Column | Type | Collation | Nullable | Default--------+---------------+-----------+----------+--------- id | integer | | | name | text | | | price | numeric(10,2) | | |นั่นสร้าง table ที่มีสาม column: id เป็นจำนวนเต็ม, name เป็นข้อความ และ price เก็บเป็นทศนิยมแม่นยำสูงสุดสิบหลักและสองหลักหลังจุด ใช้งานได้ แต่หลวมเกินไป — ทุก column เป็น nullable และไม่มี default ไม่มีอะไรห้ามสองสินค้าใช้ id เดียวกัน หรือ row ที่ว่างเปล่าทั้งแถว:
-- sql/loose-rows.sqlINSERT INTO products VALUES (1, 'Notebook', 4.50), (1, 'Pen', NULL), (NULL, NULL, NULL);SELECT * FROM products;INSERT 0 3 id | name | price----+----------+------- 1 | Notebook | 4.50 1 | Pen | | |(3 rows)เข้าไปสาม row สองตัว id ซ้ำกัน และหนึ่งตัวไม่มีอะไรเลย เราจะแก้ด้วย primary key และ constraint ต่อไป
ใน pgAdmin: หลังรัน statement ใน Query Tool ให้ refresh node Tables ใน browser tree แล้ว table products ใหม่จะปรากฏ ขยายดูจะเห็น node Columns ที่แสดงแต่ละ column และ type
การสร้าง primary key อัตโนมัติ
หัวข้อที่มีชื่อว่า “การสร้าง primary key อัตโนมัติ”คุณแทบไม่อยากกำหนดเลข id ด้วยมือ PostgreSQL ทำให้ได้ วิธีสมัยใหม่คือ GENERATED ALWAYS AS IDENTITY ที่ให้เซิร์ฟเวอร์ใส่ค่าถัดไปให้อัตโนมัติ ทางลัดแบบเก่าคือ SERIAL ซึ่งคุณยังเห็นในโค้ดเดิมมากมาย
-- sql/identity-table.sqlDROP TABLE products;CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);\d productsDROP TABLECREATE TABLE Table "public.products" Column | Type | Collation | Nullable | Default--------+---------------+-----------+----------+------------------------------ id | bigint | | not null | generated always as identity name | text | | not null | price | numeric(10,2) | | not null |Indexes: "products_pkey" PRIMARY KEY, btree (id)ตอนนี้ id ถูกเติมโดยเซิร์ฟเวอร์ ประกาศเป็น primary key (not null แฝงมาด้วย และสร้าง index products_pkey) และ name กับ price ปล่อยว่างไม่ได้อีกต่อไป \d แสดง default generated always as identity table เดียวกันที่เขียนแบบเก่าจะใช้ id bigserial PRIMARY KEY ทั้งสองแบบให้ key ที่เพิ่มค่าอัตโนมัติ แต่แบบ IDENTITY เป็นมาตรฐาน SQL และเป็นที่นิยมใน PostgreSQL สมัยใหม่ (docs)
-- sql/identity-insert.sqlINSERT INTO products (name, price) VALUES ('Notebook', 4.50), ('Pen', 1.20) RETURNING id, name; id | name----+---------- 1 | Notebook 2 | Pen(2 rows)
INSERT 0 2ALWAYS, BY DEFAULT และสิ่งที่ serial ทำต่างออกไป
หัวข้อที่มีชื่อว่า “ALWAYS, BY DEFAULT และสิ่งที่ serial ทำต่างออกไป”GENERATED ALWAYS ปฏิเสธค่าที่คุณส่งเข้าไป ด้วย error ที่บอกวิธีแก้ (SQLSTATE 428C9):
-- @expect-error 428C9INSERT INTO products (id, name, price) VALUES (100, 'Stapler', 8.00);ERROR: cannot insert a non-DEFAULT value into column "id"DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.HINT: Use OVERRIDING SYSTEM VALUE to override.OVERRIDING SYSTEM VALUE คือทางหนีที่ตั้งใจใช้ (bulk load, restore) สังเกตว่ามัน ไม่ ทำให้ sequence เดินหน้า:
-- sql/overriding.sqlINSERT INTO products (id, name, price) OVERRIDING SYSTEM VALUE VALUES (100, 'Stapler', 8.00);SELECT id, name FROM products ORDER BY id;INSERT 0 1 id | name-----+---------- 1 | Notebook 2 | Pen 100 | Stapler(3 rows)GENERATED BY DEFAULT รับค่าที่ระบุเอง ซึ่งเป็นสาเหตุของการชนกันภายหลัง: ตัวสร้างไม่รู้ว่าคุณ insert 1 ด้วยมือไปแล้ว
-- sql/by-default.sqlCREATE TABLE tags (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, label text NOT NULL);INSERT INTO tags (id, label) VALUES (1, 'imported');-- @expect-error 23505INSERT INTO tags (label) VALUES ('first generated');ERROR: duplicate key value violates unique constraint "tags_pkey"DETAIL: Key (id)=(1) already exists.SERIAL เป็นชวเลขของ sequence แยกต่างหากบวก default nextval(...) ดูที่ column Default และเทียบว่า column แต่ละแบบเป็นเจ้าของ sequence ตัวไหน:
-- sql/serial.sqlCREATE TABLE legacy (id bigserial PRIMARY KEY, name text NOT NULL);\d legacySELECT pg_get_serial_sequence('legacy', 'id') AS serial_sequence, pg_get_serial_sequence('products', 'id') AS identity_sequence;CREATE TABLE Table "public.legacy" Column | Type | Collation | Nullable | Default--------+--------+-----------+----------+------------------------------------ id | bigint | | not null | nextval('legacy_id_seq'::regclass) name | text | | not null |Indexes: "legacy_pkey" PRIMARY KEY, btree (id)
serial_sequence | identity_sequence----------------------+------------------------ public.legacy_id_seq | public.products_id_seq(1 row)sequence แยกต่างหากนั้นคือ “ข้อแปลกเรื่องความเป็นเจ้าของ” ที่คนพูดถึง: table เป็น object หนึ่ง sequence เป็นอีกหนึ่ง และสิทธิ์ถูกตรวจบนทั้งสอง role ที่ INSERT เข้า table แบบ serial ได้แต่ไม่มีสิทธิ์บน sequence จะล้มเหลว ส่วน identity column ซึ่ง sequence เป็นของภายใน ไม่ล้มเหลว:
-- sql/sequence-privileges.sqlCREATE ROLE writer LOGIN PASSWORD '<writer-password>';GRANT INSERT ON legacy, products TO writer;SET ROLE writer;INSERT INTO products (name, price) VALUES ('Eraser', 0.80);RESET ROLE;CREATE ROLEGRANTSETINSERT 0 1RESET-- @expect-error 42501BEGIN;SET ROLE writer;INSERT INTO legacy (name) VALUES ('Eraser');BEGINSETERROR: permission denied for sequence legacy_id_seqsequence ไม่เคย rollback การ insert ที่ถูก rollback ก็ยังใช้ค่าไปหนึ่งค่า identity column จึงมีช่องว่างโดยการออกแบบ (อย่าใช้เป็นตัวนับที่ไม่มีช่องว่าง) ด้านบน Eraser ได้ id 3, Discarded ที่ถูก rollback เผาค่า 4 ทิ้ง และ Ruler ได้ 5:
-- sql/identity-gaps.sqlBEGIN;INSERT INTO products (name, price) VALUES ('Discarded', 1.00);ROLLBACK;INSERT INTO products (name, price) VALUES ('Ruler', 2.10) RETURNING id;BEGININSERT 0 1ROLLBACK id---- 5(1 row)
INSERT 0 1การสร้าง table จาก driver
หัวข้อที่มีชื่อว่า “การสร้าง table จาก driver”แอปพลิเคชันส่วนใหญ่รัน CREATE TABLE ครั้งเดียวตอนติดตั้งหรือผ่านขั้นตอน migration ไม่ใช่ทุก request นี่คือ migration เดียวกันที่ส่งจากแต่ละ driver — มีประโยชน์เมื่อแอปพลิเคชันของคุณเป็นเจ้าของ schema
CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);await pool.query(` CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )`);cur.execute(""" CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )""")_, err := conn.Exec(ctx, ` CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )`)sqlx::query( "CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL )",).execute(&pool).await?;SQL เหมือนกันทุกที่ ต่างกันแค่วิธีส่งให้เซิร์ฟเวอร์ เพราะ DDL ไม่รับ input ของผู้ใช้ จึงไม่มี parameter ให้ bind แต่ละ snippet รันหนึ่งครั้งกับฐานข้อมูลชั่วคราว แล้วถามว่า products มีกี่ column:
$ node app.mjs # pg 8.23.0 (count(*) is a bigint, which node-postgres returns as a string){ columns: '3' }
$ python app.py # psycopg 3.3.6(3,)
$ go run . # pgx v5.11.0columns: 3
$ cargo run # sqlx 0.9.0columns: 3IF NOT EXISTS และ DROP TABLE
หัวข้อที่มีชื่อว่า “IF NOT EXISTS และ DROP TABLE”การรัน CREATE TABLE products ครั้งที่สองเป็น error เพราะ table มีอยู่แล้ว (SQLSTATE 42P07):
-- @expect-error 42P07CREATE TABLE products (id integer);ERROR: relation "products" already existsการเพิ่ม IF NOT EXISTS ทำให้ statement ไม่ทำอะไรเมื่อ table มีอยู่แล้ว และบอกคุณด้วย NOTICE ซึ่งสะดวกในสคริปต์ setup ที่อาจรันมากกว่าหนึ่งครั้ง สังเกตว่ามันตรวจแค่ ชื่อ: ไม่ได้ตรวจว่า table ที่มีอยู่มี column ตามที่คุณขอหรือไม่
-- sql/if-not-exists.sqlCREATE TABLE IF NOT EXISTS products (id integer);NOTICE: relation "products" already exists, skippingCREATE TABLEเพื่อลบ table และทุก row ของมัน ใช้ DROP TABLE มันถาวรและทันที ควรระวังเท่ากับ DELETE DROP TABLE IF EXISTS หลีกเลี่ยง error เมื่อ table หายไปแล้ว PostgreSQL มี DDL แบบ transactional ดังนั้นการ drop rollback ได้ — วิธีที่ปลอดภัยที่สุดในการดูว่า DROP จะทำอะไร:
-- sql/drop-in-transaction.sqlBEGIN;DROP TABLE legacy;SELECT to_regclass('legacy') AS inside_tx;ROLLBACK;SELECT to_regclass('legacy') AS after_rollback;BEGINDROP TABLE inside_tx-----------
(1 row)
ROLLBACK after_rollback---------------- legacy(1 row)ใน pgAdmin: คุณ drop table ได้โดยคลิกขวาใน browser tree แล้วเลือก Delete/Drop แต่การรัน DROP TABLE ใน Query Tool เก็บการกระทำไว้ในประวัติสคริปต์ของคุณ
ชื่อถูกแปลงเป็นตัวพิมพ์เล็กเว้นแต่ครอบด้วย double quote ดังนั้น Widgets กับ "Widgets" เป็นสอง table ที่ต่างกัน:
-- sql/case-folding.sqlCREATE TABLE Widgets (Id int);CREATE TABLE "Widgets" ("Id" int);\dt *idgetsCREATE TABLECREATE TABLE List of tables Schema | Name | Type | Owner--------+---------+-------+---------- public | Widgets | table | postgres public | widgets | table | postgres(2 rows)Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”ผู้ช่วย AI ที่ถูกขอ “สคริปต์ seed ที่มี id ตายตัวเพื่อให้เทสต์คงที่” เขียนสิ่งนี้ อ่านแล้วดูดี:
🐛 หา bug ในโค้ดนี้
-- seed.sql (AI comment: "seed a few products with fixed ids so tests are stable")CREATE TABLE products (id serial PRIMARY KEY,name text NOT NULL,price numeric(10, 2) NOT NULL);
INSERT INTO products (id, name, price) VALUES (1, 'Notebook', 4.50), (2, 'Pen', 1.20);
-- later, from the application:INSERT INTO products (name, price) VALUES ('Stapler', 8.00);เฉลย
bug จะโผล่ตอนแอปพลิเคชัน insert row แรกเท่านั้น serial ให้ sequence แก่ column แต่ id ที่ระบุเองใน seed ไม่เคยแตะมัน sequence จึงยังเริ่มที่ 1 และ id ตัวแรกที่ถูกสร้างชนกับตัวที่ seed ไว้:
-- sql/seed-buggy.sqlCREATE TABLE seeded (id serial PRIMARY KEY, name text NOT NULL);INSERT INTO seeded (id, name) VALUES (1, 'Notebook'), (2, 'Pen');-- @expect-error 23505INSERT INTO seeded (name) VALUES ('Stapler');ERROR: duplicate key value violates unique constraint "seeded_pkey"DETAIL: Key (id)=(1) already exists.แก้ได้สองวิธี วิธีที่ดีที่สุดคือไม่กำหนด id ด้วยมือเลย (identity column ตัวสร้างให้ id ออกมาเป็น 3 หลังตัวสร้าง insert สอง row ที่ seed):
-- sql/seed-fixed.sqlCREATE TABLE seeded_fixed (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL);INSERT INTO seeded_fixed (name) VALUES ('Notebook'), ('Pen');INSERT INTO seeded_fixed (name) VALUES ('Stapler') RETURNING id;CREATE TABLEINSERT 0 2 id---- 3(1 row)
INSERT 0 1ถ้าจำเป็นต้อง seed id ที่ระบุเอง ให้เลื่อน sequence ข้ามมันไปภายหลังด้วย setval:
-- sql/seed-resync.sqlCREATE TABLE seeded_serial (id serial PRIMARY KEY, name text NOT NULL);INSERT INTO seeded_serial (id, name) VALUES (1, 'Notebook'), (2, 'Pen');SELECT setval(pg_get_serial_sequence('seeded_serial', 'id'), (SELECT max(id) FROM seeded_serial));INSERT INTO seeded_serial (name) VALUES ('Stapler') RETURNING id;CREATE TABLEINSERT 0 2 setval-------- 2(1 row)
id---- 3(1 row)
INSERT 0 1สุดท้าย ลบ role ฝึกหัดจากตัวอย่างเรื่องสิทธิ์:
-- sql/cleanup.sqlREVOKE ALL ON legacy, products FROM writer;DROP ROLE writer;REVOKEDROP ROLEMental Model
หัวข้อที่มีชื่อว่า “Mental Model”นิยามของ table คือสัญญาที่เซิร์ฟเวอร์บังคับใช้กับทุกการเขียน primary key คือตัวตนของ row และ identity column คือ sequence ที่ column เป็นเจ้าของ ซึ่งถูกนำออกจากระบบ transaction โดยตั้งใจ: มันแจกเลขที่ไม่ซ้ำ ไม่ใช่เลขที่ต่อเนื่อง และไม่เคยเรียกคืน ทุกเรื่องของ key อัตโนมัติมาจากตรงนี้: ช่องว่างหลัง rollback, การชนกันหลัง insert ด้วยมือ, และ sequence ที่คุณต้องซิงก์เองเมื่อเลี่ยงมัน
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ใช้
GENERATED ALWAYS AS IDENTITYแทนSERIALสำหรับ table ใหม่ เป็น syntax มาตรฐาน SQL เลี่ยงข้อแปลกเรื่องสิทธิ์ของ sequence แยกที่พิสูจน์ข้างบน และอ่านชัดกว่า - ใช้
bigintแทนintegerสำหรับ identity key ของ table ที่คาดว่าจะโตinteger4 ไบต์หมดที่ 2,147,483,647 row (หรือเร็วกว่านั้น เพราะ sequence มีช่องว่าง) และการเปลี่ยน type ภายหลังกระทบมาก - เพิ่ม
IF NOT EXISTSให้สคริปต์สร้างที่อาจรันซ้ำ แต่เว้นไว้สำหรับ migration ครั้งเดียวที่ table ซ้ำควรเป็น error ที่คุณสังเกตเห็น DROP TABLEลบทั้งโครงสร้างและทุก row โดยไม่ถามยืนยัน ตรวจชื่อ table ซ้ำ และควร drop ใน transaction ที่ rollback ได้หากไม่แน่ใจ- ชื่อ column และ table ถูกแปลงเป็นตัวพิมพ์เล็กเว้นแต่ครอบด้วย double quote จึงเลือกชื่อตัวพิมพ์เล็กแบบ snake_case เพื่อเลี่ยงเซอร์ไพรส์
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| ออกแบบ schema เต็มรูปแบบล่วงหน้า | โครงสร้างชัดเจน มีวินัย migration ตั้งแต่วันแรก | ใช้เวลารวบรวม requirement นานก่อนเริ่มเขียนโค้ด |
| เพิ่ม column ภายหลังเมื่อมี requirement จริง | ยืดหยุ่น ปรับตัวกับธุรกิจได้เร็ว | schema drift — ต้องมาเติม constraint และ migration ทีหลัง |
primary key แบบ bigint GENERATED ALWAYS AS IDENTITY | เรียงตามเวลา insert ได้ เล็ก สร้าง index เร็ว | id ถัดไปเดาได้ และเผยจำนวน row |
primary key แบบ uuid | ไม่ซ้ำทั่วโลก สร้างฝั่ง client ได้โดยไม่ต้องไปกลับเซิร์ฟเวอร์ | ใหญ่กว่า (16 ไบต์ เทียบ 8) v4 แบบสุ่มทำให้ index กระจาย (วัดผลในบทถัดไป) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “id แบบ identity/serial ต่อเนื่องกัน” มันไม่ซ้ำ แต่ไม่ไร้ช่องว่าง: insert ที่ถูก rollback เผาค่าทิ้ง (พิสูจน์ข้างบน: 3, 5)
- “
IF NOT EXISTSทำให้ table ถูกต้อง” มันแค่ข้ามการสร้างเมื่อ ชื่อ ซ้ำ ไม่ว่า column จะเป็นอะไร - “table ที่ไม่มี primary key ก็ไม่เป็นไร” ทำได้ตามกฎ (table แรกข้างบนรับ row ซ้ำและ row ที่เป็น NULL ทั้งแถว) แต่การ update หรือ delete row ใด row หนึ่งจะยาก และ logical replication ต้องใช้ replica identity
- “
varchar(255)คือค่าเริ่มต้นที่ปลอดภัย” ใน PostgreSQLtextกับvarchar(n)เก็บแบบเดียวกัน 255 ที่คัดลอกมาไม่ป้องกันอะไร บทถัดไปครอบคลุมเรื่องนี้ - “
DROP TABLEย้อนกลับไม่ได้” นอก transaction ย้อนไม่ได้ แต่ใน transaction rollback ได้ (พิสูจน์ข้างบน)