Data type
data type คือการตัดสินใจที่สำคัญที่สุดข้อเดียวที่คุณทำกับ column มันควบคุมว่าค่าอะไรใส่ได้ ใช้พื้นที่เท่าไร เรียงและเปรียบเทียบอย่างไร และ operator ตัวไหนใช้ได้ PostgreSQL มี type ในตัวหลากหลายและยังให้คุณนิยามเองได้ บทเรียนนี้ทัวร์ type ที่คุณจะหยิบใช้บ่อยที่สุด และพิสูจน์ข้ออ้างที่มักถูกพูดซ้ำโดยไม่มีหลักฐาน
ทุก output มาจากการรัน fence จริงบน PostgreSQL 18.6 (
postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) ในฐานข้อมูลเปล่าหนึ่งตัว จากบนลงล่าง ตัวเลขเวลามาจาก Mac เครื่องนี้และจะต่างกันในเครื่องของคุณ
สำหรับจำนวนเต็ม integer (สี่ไบต์ ถึงประมาณสองพันล้าน) ครอบคลุม counter และ id ส่วนใหญ่ ขณะที่ bigint (แปดไบต์) รองรับช่วงที่ใหญ่กว่า สำหรับค่าที่ต้องการความแม่นยำ — โดยเฉพาะเงิน — ใช้ numeric ซึ่งเก็บทศนิยมอย่างแม่นยำ type floating-point real และ double precision เร็วแต่เป็นค่าประมาณ จึงเก็บไว้ใช้กับข้อมูลวิทยาศาสตร์หรือการวัดที่ยอมรับการปัดเศษเล็กน้อยได้
-- sql/numbers.sqlCREATE TABLE measurements ( count_int integer, count_big bigint, price numeric(10, 2), reading real);
INSERT INTO measurements VALUES (42, 9000000000, 19.99, 3.14);SELECT * FROM measurements;CREATE TABLEINSERT 0 1 count_int | count_big | price | reading-----------+------------+-------+--------- 42 | 9000000000 | 19.99 | 3.14(1 row)“ค่าประมาณ” ไม่ใช่นามธรรม นี่คือตัวอย่างคลาสสิก รันบนเซิร์ฟเวอร์: binary floating point แทน 0.1 และ 0.2 ได้ไม่พอดี ผลรวมจึงไม่ใช่ 0.3 ขณะที่ numeric แม่นยำ:
-- sql/float-vs-numeric.sqlSELECT 0.1::float8 + 0.2::float8 AS float_sum, 0.1::float8 + 0.2::float8 = 0.3::float8 AS float_equals, 0.1::numeric + 0.2::numeric AS numeric_sum, 0.1::numeric + 0.2::numeric = 0.3::numeric AS numeric_equals; float_sum | float_equals | numeric_sum | numeric_equals---------------------+--------------+-------------+---------------- 0.30000000000000004 | f | 0.3 | t(1 row)จำนวนเต็มไม่ wrap around แต่ปฏิเสธ ขีดจำกัดของ integer คือ 2,147,483,647 และ column numeric(10, 2) เก็บค่าที่น้อยกว่า 10^8:
-- @expect-error 22003SELECT 2147483647::integer + 1;ERROR: integer out of range-- @expect-error 22003INSERT INTO measurements (price) VALUES (100000000.00);ERROR: numeric field overflowDETAIL: A field with precision 10, scale 2 must round to an absolute value less than 10^8.numeric(p, s) ปัดตาม scale แทนที่จะล้มเหลวเมื่อขนาดค่าไม่เกิน และ bigint ให้ช่วง 64 บิต (ประมาณ 9.2 x 10^18):
-- sql/numeric-rounding.sqlSELECT 19.999::numeric(10, 2) AS rounded, 9000000000::bigint * 1000000000 AS still_fits_bigint; rounded | still_fits_bigint---------+--------------------- 20.00 | 9000000000000000000(1 row)type money
หัวข้อที่มีชื่อว่า “type money”PostgreSQL ยังมี type money เป็น fixed-point ที่ scale และสัญลักษณ์สกุลเงินมาจากการตั้งค่า locale lc_monetary ค่าเดียวกันจึงพิมพ์ต่างกันภายใต้การตั้งค่าอื่น และคู่มือเตือนว่าข้อมูล money อาจโหลดเข้าฐานข้อมูลที่ lc_monetary ต่างกันไม่ได้ (docs) มันยังเก็บเฉพาะหลักทศนิยมของ locale (สองหลักที่นี่) ความแม่นยำจึงหายไปตั้งแต่ตอนใส่:
-- sql/money.sqlSHOW lc_monetary;SELECT 1234.567::numeric::money AS rounded_to_two_places, 1234.567::numeric AS numeric_keeps_it; lc_monetary------------- en_US.utf8(1 row)
rounded_to_two_places | numeric_keeps_it-----------------------+------------------ $1,234.57 | 1234.567(1 row)ใช้ numeric(12, 2) (หรือจำนวนเต็มของเซ็นต์) สำหรับจำนวนเงินแทน
Generated column
หัวข้อที่มีชื่อว่า “Generated column”generated column คำนวณค่าจาก column อื่นใน row เดียวกัน ใน PostgreSQL 18 แบบเริ่มต้นคือ virtual คำนวณตอนอ่าน เติม STORED เพื่อคำนวณตอนเขียนและเก็บลงดิสก์ (release notes, docs) ทั้งสองแบบดูเหมือนกันสำหรับ SELECT \d แยกความต่างด้วยคำ stored ท้ายบรรทัดเท่านั้น:
-- sql/generated-columns.sqlCREATE TABLE line_items ( qty int NOT NULL, unit_price numeric(10, 2) NOT NULL, total numeric(12, 2) GENERATED ALWAYS AS (qty * unit_price), total_stored numeric(12, 2) GENERATED ALWAYS AS (qty * unit_price) STORED);INSERT INTO line_items (qty, unit_price) VALUES (3, 19.99);SELECT * FROM line_items;\d line_itemsCREATE TABLEINSERT 0 1 qty | unit_price | total | total_stored-----+------------+-------+-------------- 3 | 19.99 | 59.97 | 59.97(1 row)
Table "public.line_items" Column | Type | Collation | Nullable | Default--------------+---------------+-----------+----------+---------------------------------------------------------- qty | integer | | not null | unit_price | numeric(10,2) | | not null | total | numeric(12,2) | | | generated always as ((qty::numeric * unit_price)) total_stored | numeric(12,2) | | | generated always as ((qty::numeric * unit_price)) storedความต่างเชิงปฏิบัติคือ virtual column ไม่กินพื้นที่และเวลาเขียน แต่สร้าง index ไม่ได้:
-- @expect-error 0A000CREATE INDEX line_items_total_idx ON line_items (total);ERROR: indexes on virtual generated columns are not supportedข้อความ
หัวข้อที่มีชื่อว่า “ข้อความ”PostgreSQL มี type สตริงในชีวิตประจำวันสองตัว text เก็บสตริงความยาวเท่าใดก็ได้ และ varchar(n) เก็บสตริงไม่เกิน n ตัวอักษร คู่มือระบุว่าไม่มีความต่างด้านประสิทธิภาพระหว่าง type ตัวอักษร นอกจากพื้นที่เพิ่มของ char(n) ที่เติมช่องว่าง (docs) สตริง 5 ตัวอักษรเดียวกันใช้ 9 ไบต์เท่ากันในทั้งสอง type ที่นี่ ขณะที่ char(5) เก็บ hi ใน 5 ไบต์ ขีดจำกัดความยาวคือกฎ และมีการบังคับใช้:
-- sql/text-types.sqlCREATE TABLE notes ( title varchar(12), body text);
INSERT INTO notes VALUES ('Release plan', 'Ship the new dashboard on Friday.');SELECT title, body FROM notes;SELECT pg_column_size('hello'::text) AS text_bytes, pg_column_size('hello'::varchar(50)) AS varchar_bytes, octet_length('hi'::char(5)) AS char5_bytes;CREATE TABLEINSERT 0 1 title | body--------------+----------------------------------- Release plan | Ship the new dashboard on Friday.(1 row)
text_bytes | varchar_bytes | char5_bytes------------+---------------+------------- 9 | 9 | 5(1 row)-- @expect-error 22001INSERT INTO notes (title) VALUES ('Release plan v2');ERROR: value too long for type character varying(12)ใช้ varchar(n) เมื่อขีดจำกัดเป็นกฎทางธุรกิจจริงเท่านั้น ไม่เช่นนั้นใช้ text (คุณเพิ่ม CHECK (length(...) <= n) ทีหลังได้เสมอ ซึ่งเปลี่ยนง่ายกว่าเปลี่ยน type ของ column)
Boolean
หัวข้อที่มีชื่อว่า “Boolean”column boolean เก็บ true, false หรือ null PostgreSQL รับหลายรูปแบบตอน input — true, 't', 'yes', '1', 'off' — แต่แสดงเป็น t หรือ f เสมอ อย่างไรก็ตามมันเข้มงวดเรื่อง type: จำนวนเต็ม 1 เปล่า ๆ ถูกปฏิเสธใน INSERT แม้การ cast แบบชัดเจนจะใช้ได้
-- sql/booleans.sqlSELECT true AS yes, false AS no, null::boolean AS unknown;SELECT 't'::boolean AS t, 'yes'::boolean AS yes, '1'::boolean AS one, 'off'::boolean AS off, 1::boolean AS int_cast;CREATE TABLE flags (f boolean); yes | no | unknown-----+----+--------- t | f |(1 row)
t | yes | one | off | int_cast---+-----+-----+-----+---------- t | t | t | f | t(1 row)
CREATE TABLE-- @expect-error 42804INSERT INTO flags VALUES (1);ERROR: column "f" is of type boolean but expression is of type integerLINE 1: INSERT INTO flags VALUES (1); ^HINT: You will need to rewrite or cast the expression.วันที่และเวลา
หัวข้อที่มีชื่อว่า “วันที่และเวลา”สำหรับวันตามปฏิทินโดยไม่มีเวลา ใช้ date สำหรับช่วงเวลาหนึ่ง ใช้ timestamptz — ย่อจาก timestamp with time zone มันไม่ได้เก็บ zone: มันเก็บ instant (เป็น UTC) และแปลงเข้าออกตามการตั้งค่า TimeZone ของ session type timestamp ธรรมดาเก็บข้อความ wall-clock ตามที่คุณให้ ซึ่งดูคล้ายแต่สูญเสียบริบทนั้นและเป็นแหล่ง bug ทั่วไป
-- sql/dates.sqlSELECT current_date AS today, now() AS moment, date '2026-01-31' AS a_day; today | moment | a_day------------+-------------------------------+------------ 2026-09-30 | 2026-09-30 06:03:17.651923+00 | 2026-01-31(1 row)row เดียวกัน สอง column อ่านจาก session ที่ตั้งเวลา Bangkok column timestamptz แสดง instant เดียวกันเป็น 19:00+07 ส่วน timestamp ธรรมดายังคงเป็น 12:00 และเมินค่า offset ที่คุณเขียนใส่ลงไปแบบเงียบ ๆ:
-- sql/timestamptz-vs-timestamp.sqlCREATE TABLE stamps (with_tz timestamptz, without_tz timestamp);SET TIME ZONE 'UTC';INSERT INTO stamps VALUES ('2026-01-31 12:00:00+00', '2026-01-31 12:00:00');SET TIME ZONE 'Asia/Bangkok';SELECT with_tz, without_tz FROM stamps;SELECT '2026-01-31 12:00:00+07'::timestamp AS offset_silently_dropped;RESET TIME ZONE;CREATE TABLESETINSERT 0 1SET with_tz | without_tz------------------------+--------------------- 2026-01-31 19:00:00+07 | 2026-01-31 12:00:00(1 row)
offset_silently_dropped------------------------- 2026-01-31 12:00:00(1 row)
RESETuuid เก็บ identifier ที่ไม่ซ้ำทั่วโลกขนาด 128 บิต UUID เหมาะเป็น primary key เมื่อ id ต้องถูกสร้างโดย client หรือต้องเดาไม่ได้ เพราะไม่เปิดเผยจำนวน row แบบ sequence
PostgreSQL 18 เพิ่ม uuidv7() และ alias uuidv4() สำหรับ gen_random_uuid() (release notes, functions) เวอร์ชัน 7 ใส่ timestamp ระดับมิลลิวินาทีไว้ในบิตสูง ค่าจึงเรียงตามเวลาสร้าง:
-- sql/uuid.sqlSELECT gen_random_uuid() AS v4_a_random, uuidv4() AS v4_same_function, uuidv7() AS v7_time_ordered;SELECT uuid_extract_version(uuidv7()) AS v7_version, uuid_extract_timestamp(uuidv7()) IS NOT NULL AS has_timestamp; v4_a_random | v4_same_function | v7_time_ordered--------------------------------------+--------------------------------------+-------------------------------------- 76528519-cbe7-480b-9606-687ec4f3277f | 5f5476e0-b3c4-4088-b9ac-f5f8cac259b4 | 01a0f0e9-03df-7fa4-af78-f7fe110171b6(1 row)
v7_version | has_timestamp------------+--------------- 7 | t(1 row)-- sql/uuid-ordering.sqlWITH s AS (SELECT n, uuidv7() AS u FROM generate_series(1, 1000) n)SELECT array_agg(u ORDER BY n) = array_agg(u ORDER BY u) AS v7_sorted_in_creation_order FROM s; v7_sorted_in_creation_order----------------------------- t(1 row)ข้ออ้างที่พบบ่อยคือการเรียงลำดับนี้ทำให้ B-tree ของ primary key กะทัดรัดและเป็นมิตรกับ cache เพราะการ insert ลงที่ขอบขวา ขณะที่ v4 แบบสุ่มกระจายทั่วต้นไม้ มันปรากฏจริงไหม? หนึ่งล้าน row ต่อ table วัดบน Mac เครื่องนี้ (รันครั้งเดียว เวลา insert แปรผันระหว่างรอบ ขนาดและ correlation คงที่):
-- sql/uuid-index-experiment.sqlCREATE TABLE ids_v4 (id uuid PRIMARY KEY DEFAULT uuidv4(), n int);CREATE TABLE ids_v7 (id uuid PRIMARY KEY DEFAULT uuidv7(), n int);\timing onINSERT INTO ids_v4 (n) SELECT g FROM generate_series(1, 1000000) g;INSERT INTO ids_v7 (n) SELECT g FROM generate_series(1, 1000000) g;\timing offANALYZE ids_v4, ids_v7;SELECT s.tablename AS kind, pg_size_pretty(pg_relation_size(s.tablename || '_pkey')) AS pkey_size, pg_size_pretty(pg_relation_size(s.tablename::regclass)) AS heap_size, round(s.correlation::numeric, 3) AS key_vs_heap_correlationFROM pg_stats sWHERE s.tablename IN ('ids_v4', 'ids_v7') AND s.attname = 'id'ORDER BY 1;CREATE TABLECREATE TABLETiming is on.INSERT 0 1000000Time: 2949.380 ms (00:02.949)INSERT 0 1000000Time: 1789.588 ms (00:01.790)Timing is off.ANALYZE kind | pkey_size | heap_size | key_vs_heap_correlation--------+-----------+-----------+------------------------- ids_v4 | 37 MB | 50 MB | 0.007 ids_v7 | 30 MB | 50 MB | 1.000(2 rows)index ของ primary key แบบ v4 ใหญ่กว่า (37 MB เทียบกับ 30 MB ในรอบที่แสดง) และการโหลดช้ากว่า เพราะ key สุ่มลงทั่วต้นไม้และทำให้เกิด page split key_vs_heap_correlation คือมุมมองด้านสถิติของเรื่องเดียวกัน: key แบบ v7 เรียงตามลำดับทางกายภาพ (1.0) ส่วน key แบบ v4 ไม่เกี่ยวข้องกับมัน (ประมาณ 0) ทั้งสอง table มี heap 50 MB เท่ากัน ความต่างจึงอยู่ที่ index ทั้งหมด คุณจะรู้สึกหรือไม่ขึ้นกับว่า index ใส่ในหน่วยความจำได้หรือไม่ หยิบ uuidv7() สำหรับ table ใหม่เมื่อ id เป็น primary key เก็บ gen_random_uuid() ไว้เมื่อ id ต้องไม่เปิดเผยว่า row ถูกสร้างเมื่อใด เพราะ timestamp ของ v7 อ่านได้ (uuid_extract_timestamp)
JSON ด้วย jsonb
หัวข้อที่มีชื่อว่า “JSON ด้วย jsonb”type jsonb เก็บ JSON ในรูปแบบ binary แยกส่วนที่ query และทำ index ได้เร็ว ควรใช้แทน type json ธรรมดาที่เก็บข้อความดิบและ parse ใหม่ทุกครั้งที่เข้าถึง ความต่างเห็นได้ตอน input: json เก็บข้อความตามที่เขียน (key ซ้ำและอื่น ๆ) ส่วน jsonb ทำให้เป็นรูปแบบมาตรฐาน คงไว้เฉพาะ key ซ้ำตัวสุดท้ายและเรียง key
-- sql/jsonb.sqlCREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload jsonb);
INSERT INTO events (payload) VALUES ('{"kind": "signup", "plan": "pro"}');
SELECT payload ->> 'plan' AS plan FROM events;SELECT '{"b":1,"a":2,"a":3}'::json AS json_keeps_text, '{"b":1,"a":2,"a":3}'::jsonb AS jsonb_normalises;CREATE TABLEINSERT 0 1 plan------ pro(1 row)
json_keeps_text | jsonb_normalises---------------------+------------------ {"b":1,"a":2,"a":3} | {"a": 3, "b": 1}(1 row)type ใดก็เป็น array ได้โดยเติมวงเล็บเหลี่ยม text[] จึงเป็น column ของ array ข้อความ array เหมาะกับรายการเล็ก ๆ ที่เรียงลำดับซึ่งเป็นของ row เดียว เช่น tag array literal เขียนด้วยวงเล็บปีกกาในสตริงที่มี quote และ subscript เริ่มที่ 1
-- sql/arrays.sqlCREATE TABLE articles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tags text[]);
INSERT INTO articles (tags) VALUES ('{"sql", "postgres", "intro"}');SELECT tags, tags[1] AS first_tag FROM articles;CREATE TABLEINSERT 0 1 tags | first_tag----------------------+----------- {sql,postgres,intro} | sql(1 row)Enumerated type
หัวข้อที่มีชื่อว่า “Enumerated type”เมื่อ column เก็บได้เฉพาะชุด label ที่คงที่ enum ทำให้ค่าที่อนุญาตเป็นส่วนของ type คุณนิยามครั้งเดียวด้วย CREATE TYPE แล้วใช้เหมือน type ในตัว ค่าเรียงตามลำดับที่คุณลิสต์ ไม่ใช่ตามตัวอักษร (pending, paid, cancelled ด้านล่างคือลำดับที่ประกาศ):
-- sql/enum.sqlCREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status order_status NOT NULL DEFAULT 'pending');
INSERT INTO orders (status) VALUES ('paid'), ('pending'), ('cancelled');SELECT id, status FROM orders ORDER BY status;CREATE TYPECREATE TABLEINSERT 0 3 id | status----+----------- 2 | pending 1 | paid 3 | cancelled(3 rows)enum ยืดหยุ่นน้อยกว่าที่เห็น คุณเพิ่มค่าในตำแหน่งที่เลือกและเปลี่ยนชื่อได้ แต่เรียงค่าที่มีอยู่ใหม่ไม่ได้และ drop ค่าไม่ได้:
-- sql/enum-alter.sqlALTER TYPE order_status ADD VALUE 'refunded' AFTER 'shipped';ALTER TYPE order_status RENAME VALUE 'cancelled' TO 'canceled';SELECT unnest(enum_range(NULL::order_status)) AS labels_in_order;ALTER TYPEALTER TYPE labels_in_order----------------- pending paid shipped refunded canceled(5 rows)-- @expect-error 0A000ALTER TYPE order_status DROP VALUE 'pending';ERROR: dropping an enum value is not implementedLINE 1: ALTER TYPE order_status DROP VALUE 'pending'; ^ค่าที่เพิ่งเพิ่มก็ใช้ใน transaction ที่เพิ่มมันไม่ได้:
-- @expect-error 55P04BEGIN;ALTER TYPE order_status ADD VALUE 'archived';SELECT 'archived'::order_status;BEGINALTER TYPEERROR: unsafe use of new value "archived" of enum type order_statusLINE 1: SELECT 'archived'::order_status; ^HINT: New enum values must be committed before they can be used.ถ้าชุดค่าเปลี่ยนบ่อย table lookup เล็ก ๆ ที่มี foreign key ยืดหยุ่นกว่า:
-- sql/enum-lookup.sqlCREATE TABLE order_statuses (code text PRIMARY KEY, sort_order int NOT NULL);INSERT INTO order_statuses VALUES ('pending', 1), ('paid', 2), ('shipped', 3);ใน pgAdmin: type ที่สร้างด้วย CREATE TYPE ปรากฏใต้ Schemas แล้ว public แล้ว Types ใน browser tree ข้างโหนด Tables คุณตรวจ label ที่อนุญาตของ enum ได้ที่นั่นโดยไม่ต้องเขียน query
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”ผู้ช่วย AI ที่ได้รับคำสั่งว่า “ราคาก็แค่ตัวเลข ขอให้เร็ว” เขียน schema นี้พร้อมคอมเมนต์ที่ดูช่วยเหลือ อ่านในฐานะ reviewer:
🐛 หา bug ในโค้ดนี้
-- schema.sql (AI comment: "double precision is fast and precise enough for prices")CREATE TABLE invoices (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,amount double precision NOT NULL,issued_at timestamp NOT NULL DEFAULT now());
CREATE TABLE payments (invoice_id bigint NOT NULL REFERENCES invoices (id),amount double precision NOT NULL);
-- find fully paid invoicesSELECT i.idFROM invoices iJOIN payments p ON p.invoice_id = i.idGROUP BY i.id, i.amountHAVING sum(p.amount) = i.amount;เฉลย
มันสร้างและรันได้โดยไม่มี error แม้แต่ครั้งเดียว และไม่เคยหา invoice ที่จ่ายแล้วเจอ invoice 0.30 ที่จ่ายเป็นสองงวด 0.10 กับ 0.20 ไม่ “เท่ากัน” ใน floating point (พิสูจน์ไว้ก่อนหน้าในบทเรียนนี้) การทดสอบ HAVING sum(p.amount) = i.amount จึงล้มเหลวเงียบ ๆ:
-- sql/invoices-float.sqlCREATE TABLE invoices ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, amount double precision NOT NULL, issued_at timestamp NOT NULL DEFAULT now());CREATE TABLE payments ( invoice_id bigint NOT NULL REFERENCES invoices (id), amount double precision NOT NULL);INSERT INTO invoices (amount) VALUES (0.3);INSERT INTO payments VALUES (1, 0.1), (1, 0.2);SELECT i.id AS fully_paid_invoiceFROM invoices iJOIN payments p ON p.invoice_id = i.idGROUP BY i.id, i.amountHAVING sum(p.amount) = i.amount;SELECT sum(amount) AS paid, sum(amount) = 0.3 AS equals_amount FROM payments;CREATE TABLECREATE TABLEINSERT 0 1INSERT 0 2 fully_paid_invoice--------------------(0 rows)
paid | equals_amount---------------------+--------------- 0.30000000000000004 | f(1 row)query ไม่คืน row เลย และ sum(amount) = 0.3 เป็น f column timestamp เป็น bug ที่รอวันอีกตัว (ไม่มี zone พิสูจน์ข้างบน) วิธีแก้คือ numeric สำหรับเงินและ timestamptz สำหรับเวลา:
-- sql/invoices-numeric.sqlCREATE TABLE invoices_fixed ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, amount numeric(12, 2) NOT NULL, issued_at timestamptz NOT NULL DEFAULT now());CREATE TABLE payments_fixed ( invoice_id bigint NOT NULL REFERENCES invoices_fixed (id), amount numeric(12, 2) NOT NULL);INSERT INTO invoices_fixed (amount) VALUES (0.30);INSERT INTO payments_fixed VALUES (1, 0.10), (1, 0.20);SELECT i.id AS fully_paid_invoiceFROM invoices_fixed iJOIN payments_fixed p ON p.invoice_id = i.idGROUP BY i.id, i.amountHAVING sum(p.amount) = i.amount;CREATE TABLECREATE TABLEINSERT 0 1INSERT 0 2 fully_paid_invoice-------------------- 1(1 row)Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”type คือเซตของค่าที่อนุญาตพร้อมกฎการเก็บ เปรียบเทียบ และดำเนินการกับค่านั้น ทุกข้ออ้างว่า “type ไหนดีกว่า” แท้จริงเป็นคำถามเรื่องหนึ่งในสามอย่าง: type แทนค่าได้พอดีหรือไม่ (numeric กับ float, timestamptz กับ timestamp) มันจำกัดสิ่งที่อาจมีอยู่หรือไม่ (varchar(n), enum, boolean) และการแทนค่านั้นมีต้นทุนใน index เท่าไร (uuid v4 กับ v7) ข้อที่สามให้วัด อย่าสมมติ
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ใช้
timestamptzไม่ใช่timestampกับทุกช่วงเวลา การเก็บ instant แล้วแปลงตาม session หลีกเลี่ยง bug เรื่อง time zone ทั้งกลุ่ม ใช้timestampธรรมดาเมื่อค่านั้นไม่มี zone จริง ๆ เช่นนาฬิกาปลุกท้องถิ่นที่เกิดซ้ำ - ใช้
textเป็นค่าเริ่มต้นและใช้varchar(n)เมื่อความยาวสูงสุดจริงเป็นส่วนของกฎเท่านั้น ไม่มีความต่างด้านความเร็ว และCHECKconstraint บังคับขีดจำกัดบนtextได้ถ้าจำเป็น - เก็บเงินใน
numericไม่ใช่realหรือdouble precisionbinary floating point แทนค่าอย่าง 0.10 ได้ไม่พอดี ทำให้เกิดข้อผิดพลาดจากการปัดเศษที่สะสม - ใช้
jsonbแทนjsonรูปแบบ binary query ได้เร็วกว่าและทำ index ได้ typejsonแบบข้อความมีประโยชน์เมื่อคุณต้องรักษารูปแบบ input ตามเดิมเท่านั้น enumเหมาะกับชุด label ที่คงที่จริง ๆ การเพิ่มค่าถูก (ADD VALUE ... AFTER) แต่ใช้ใน transaction เดียวกันไม่ได้ ค่า drop หรือเรียงใหม่ไม่ได้ ถ้าชุดค่าเปลี่ยนบ่อยให้ใช้ table lookup- virtual generated column (ค่าเริ่มต้นของ PostgreSQL 18) สร้าง index ไม่ได้ ใช้
STOREDเมื่อต้องการ index
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
type ของ column แบบตายตัว (integer, text, timestamptz) | บังคับความถูกต้องของข้อมูล ใช้พื้นที่น้อย เปรียบเทียบและทำ index ได้เร็ว | ต้องรู้รูปร่างข้อมูลล่วงหน้า การเปลี่ยน type ภายหลังมีต้นทุน |
jsonb สำหรับรูปร่างที่คาดเดาไม่ได้ | ยืดหยุ่นมาก ไม่ต้อง migration ทุกครั้งที่รูปร่างเปลี่ยน | validate และทำ index ยากกว่า การค้นลึกช้ากว่า column ธรรมดา |
numeric สำหรับเงิน | แม่นยำ ไม่มีข้อผิดพลาดจากการปัดเศษสะสม | ช้ากว่า floating point เล็กน้อยสำหรับการคำนวณหนัก |
float/double precision | คำนวณเร็ว เหมาะกับงานวิทยาศาสตร์ | เป็นค่าประมาณ — ไม่เหมาะกับเงินเพราะข้อผิดพลาดจากการปัดเศษสะสม |
primary key แบบ uuidv7() | เรียงตามเวลา ทำให้ index กะทัดรัด (วัดข้างบน) | เผยเวลาที่สร้าง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
varchar(n)ประหยัดพื้นที่กว่าtext” ไบต์เท่ากัน (9 กับ 9 ข้างบน) ความต่างมีแค่การตรวจความยาว - “
timestamptzเก็บ time zone” มันเก็บ instant; zone เป็นการตั้งค่าการแสดงผลของ session - “
moneyคือ type ที่ถูกต้องสำหรับเงิน” มันผูกกับ locale และปัดเป็นสองหลักตอน input ใช้numeric - “ค่า enum เป็นแค่ label ที่แก้ได้อิสระ” เพิ่มและเปลี่ยนชื่อได้ แต่เรียงใหม่หรือ drop ไม่ได้ และ label ใหม่ใช้ไม่ได้จนกว่าจะ commit
- “primary key แบบ UUID ช้ากว่าเสมอ” key v4 แบบสุ่มเสียขนาด index และเวลา insert ในการวัดข้างบน v7 ที่เรียงตามเวลาลบส่วนใหญ่ออกไป
- “
double precisionก็โอเค ความคลาดเคลื่อนนิดเดียว” ความคลาดเคลื่อนนิดเดียวก็ทำให้=เป็นเท็จ (พิสูจน์ข้างบน) ซึ่งพอที่จะทำให้การจับคู่ invoice พัง