ข้ามไปยังเนื้อหา

การ update และ delete

กริยา CRUD สองตัวสุดท้ายเปลี่ยนข้อมูลที่มีอยู่ UPDATE ตั้งค่า column ใหม่ให้ row ที่ตรงเงื่อนไข และ DELETE ลบ row ที่ตรง ทั้งสองพึ่ง clause WHERE ที่คุณเรียนจาก SELECT อย่างมาก — และทั้งสองกลายเป็นความผิดพลาดที่ทำลายล้างถ้าคุณลืมมัน

ทุกอย่างด้านล่างเป็น output จริงจาก PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) รันจากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัวที่เริ่มด้วยชุดข้อมูลมาตรฐานของคอร์ส การเปลี่ยนแปลงส่วนใหญ่ครอบด้วย BEGIN ... ROLLBACK เพื่อให้ทุกตัวอย่างเริ่มจาก row เดียวกัน command tag (UPDATE 1, DELETE 2) คือสิ่งที่การรันจริงพิมพ์

-- sql/setup.sql
CREATE TABLE authors (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
born int
);
CREATE TABLE books (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
author_id bigint REFERENCES authors (id),
published int,
copies_sold int NOT NULL DEFAULT 0
);
CREATE TABLE sales (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
book_id bigint REFERENCES books (id),
region text NOT NULL,
copies int NOT NULL
);
INSERT INTO authors (name, born) VALUES ('Mara Linde', 1968), ('Owen Pryce', 1975), ('Sela Vance', 1990);
INSERT INTO books (title, author_id, published, copies_sold) VALUES
('Quiet Harbors', 1, 2003, 450), ('The Glass Orchard', 1, 2018, 1250), ('Tidal Notes', 2, 2011, 600), ('Unlinked Draft', NULL, 2020, 0);
INSERT INTO sales (book_id, region, copies) VALUES
(2, 'East', 900), (3, 'West', 400), (1, 'West', 400), (3, 'East', 200), (2, 'West', 150), (1, 'East', 50);

UPDATE ระบุ table ลิสต์คู่ SET column = value และใช้ WHERE เลือก row ถ้าไม่มี WHERE ทุก row ถูกเปลี่ยน ค่าใหม่อ้างถึงค่าเก่าได้ ดังที่ copies_sold + 500 แสดง

-- sql/update-basic.sql
BEGIN;
UPDATE books
SET copies_sold = copies_sold + 500
WHERE id = 1;
SELECT id, title, copies_sold FROM books WHERE id = 1;
ROLLBACK;
BEGIN
UPDATE 1
id | title | copies_sold
----+---------------+-------------
1 | Quiet Harbors | 950
(1 row)
ROLLBACK

ตั้งหลาย column พร้อมกันได้โดยคั่นการกำหนดค่าด้วยเครื่องหมายจุลภาค:

-- sql/update-several-columns.sql
BEGIN;
UPDATE books SET published = 2004, copies_sold = 0 WHERE title = 'Unlinked Draft';
SELECT id, title, published, copies_sold FROM books WHERE id = 4;
ROLLBACK;
BEGIN
UPDATE 1
id | title | published | copies_sold
----+----------------+-----------+-------------
4 | Unlinked Draft | 2004 | 0
(1 row)
ROLLBACK

ใน pgAdmin: รันใน Query Tool แผงข้อความรายงาน UPDATE 1 คือจำนวน row ที่เปลี่ยน

เหมือน INSERT ทั้ง UPDATE และ DELETE รับ RETURNING นี่คือวิธีที่สะอาดที่สุดในการเห็นว่า row ไหนเปลี่ยนและค่าใหม่คืออะไร

-- sql/update-returning.sql
BEGIN;
UPDATE books
SET published = 2019
WHERE title = 'The Glass Orchard'
RETURNING id, title, published;
ROLLBACK;
BEGIN
id | title | published
----+-------------------+-----------
2 | The Glass Orchard | 2019
(1 row)
UPDATE 1
ROLLBACK

PostgreSQL 18 เพิ่ม alias old และ new ให้ RETURNING ทำให้ statement เดียวรายงานทั้งค่าก่อนและหลังได้ (release notes) ก่อน PostgreSQL 18 ต้อง SELECT ก่อนหรือใช้ trigger:

-- sql/update-returning-old-new.sql
BEGIN;
UPDATE books
SET copies_sold = copies_sold + 5
WHERE id = 2
RETURNING id, title, old.copies_sold AS was, new.copies_sold AS now;
ROLLBACK;
BEGIN
id | title | was | now
----+-------------------+------+------
2 | The Glass Orchard | 1250 | 1255
(1 row)
UPDATE 1
ROLLBACK

เพื่อตั้งค่าจาก table อื่น เพิ่ม clause FROM และ join ใน WHERE ที่นี่ copies_sold ของแต่ละหนังสือถูกคำนวณใหม่จาก row ใน sales หนังสือ 1 และ 3 ตรงกับยอดขายอยู่แล้ว หนังสือ 2 มี 200 copy ที่ไม่ปรากฏใน sales (1250 กับ 1050) และ statement จะเขียนทับมันแบบเงียบ ๆ:

-- sql/update-from.sql
BEGIN;
UPDATE books b
SET copies_sold = s.total
FROM (SELECT book_id, sum(copies) AS total FROM sales GROUP BY book_id) s
WHERE s.book_id = b.id
RETURNING b.id, b.title, old.copies_sold AS was, new.copies_sold AS now;
ROLLBACK;
BEGIN
id | title | was | now
----+-------------------+------+------
1 | Quiet Harbors | 450 | 450
2 | The Glass Orchard | 1250 | 1050
3 | Tidal Notes | 600 | 600
(3 rows)
UPDATE 3
ROLLBACK

รายการ FROM คือ join และ WHERE คือเงื่อนไข join ทรงพลังและผิดพลาดง่าย (ดูส่วนสุดท้าย)

DELETE FROM ลบ row ที่ตรงกับ clause WHERE เหมือน UPDATE คุณเพิ่ม RETURNING เพื่อดูว่าอะไรถูกลบได้

-- sql/delete-basic.sql
BEGIN;
DELETE FROM books
WHERE copies_sold = 0
RETURNING id, title;
ROLLBACK;
BEGIN
id | title
----+----------------
4 | Unlinked Draft
(1 row)
DELETE 1
ROLLBACK

DELETE ... USING คือคู่ของ UPDATE ... FROM สำหรับการลบ: ลบ row ของ sales โดยดูจาก table อื่น (ที่นี่ ทุกยอดขายของหนังสือโดย author 2):

-- sql/delete-using.sql
BEGIN;
DELETE FROM sales s
USING books b
WHERE s.book_id = b.id AND b.author_id = 2
RETURNING s.id, s.region, s.copies;
ROLLBACK;
BEGIN
id | region | copies
----+--------+--------
2 | West | 400
4 | East | 200
(2 rows)
DELETE 2
ROLLBACK

การลบถูกปฏิเสธได้เมื่อ row อื่นยังอ้างอิงเป้าหมายอยู่ ทั้งสอง statement ล้มเหลวด้วย SQLSTATE 23503 และข้อความระบุ constraint กับ table ที่อ้างอิง:

-- @expect-error 23503
DELETE FROM books WHERE id = 1;
ERROR: update or delete on table "books" violates foreign key constraint "sales_book_id_fkey" on table "sales"
DETAIL: Key (id)=(1) is still referenced from table "sales".
-- @expect-error 23503
DELETE FROM authors WHERE id = 1;
ERROR: update or delete on table "authors" violates foreign key constraint "books_author_id_fkey" on table "books"
DETAIL: Key (id)=(1) is still referenced from table "books".

UPDATE และ DELETE ใช้กับทุก row ที่ตรงเงื่อนไข — และถ้าไม่มีเงื่อนไข ทุก row ตรง DELETE FROM books; จะทำให้ table ว่าง และ UPDATE books SET copies_sold = 0; ทำให้ทุก record เป็นศูนย์ คำเตือนเดียวคือ command tag: UPDATE 4 ทั้งที่คุณคาดหวัง UPDATE 1

นิสัยที่ปลอดภัยคือรัน WHERE เดียวกันเป็น SELECT ก่อน ยืนยันจำนวน row แล้วค่อยเปลี่ยนกริยา การครอบการเปลี่ยนแปลงเสี่ยงด้วย transaction ให้ทางหนี ดังตัวอย่าง (ทั้ง table ถูกทำเป็นศูนย์ แล้วย้อนกลับ):

-- sql/missing-where.sql
SELECT count(*) AS rows_that_match FROM books WHERE author_id = 1;
BEGIN;
UPDATE books SET copies_sold = 0;
SELECT sum(copies_sold) AS total_after FROM books;
ROLLBACK;
SELECT sum(copies_sold) AS total_after_rollback FROM books;
rows_that_match
-----------------
2
(1 row)
BEGIN
UPDATE 4
total_after
-------------
0
(1 row)
ROLLBACK
total_after_rollback
----------------------
2300
(1 row)

รูปแบบเดียวกันจาก psql และ driver — ตรวจภายใน transaction แล้วค่อยตัดสินใจ:

-- sql/transaction-escape-hatch.sql
BEGIN;
DELETE FROM sales WHERE book_id = 3;
SELECT count(*) AS sales_left FROM sales; -- inspect the result, then decide:
ROLLBACK; -- or COMMIT; to keep the change
BEGIN
DELETE 2
sales_left
------------
4
(1 row)
ROLLBACK

ภายใน transaction ไม่มีอะไรถาวรจนกว่าคุณ COMMIT และ ROLLBACK ทิ้งการเปลี่ยนแปลง transaction มีโมดูลเต็มของตัวเองภายหลังในคอร์ส snippet ของ driver แต่ละตัวรันหนึ่งครั้งกับสำเนาชั่วคราวของชุดข้อมูล (ตัวที่เป็น transaction commit จริง เหลือ 4 จาก 6 row ของ sales):

$ (each snippet run once against the course dataset in a scratch database, reset before every run; the program prints what it got back)
[UPDATE (then SELECT copies_sold of book 1)] Node.js (pg 8.23.0): { copies_sold: 950 }
[UPDATE (then SELECT copies_sold of book 1)] Python (psycopg 3.3.6): (950,)
[UPDATE (then SELECT copies_sold of book 1)] Go (pgx v5.11.0): 950
[UPDATE (then SELECT copies_sold of book 1)] Rust (sqlx 0.9.0): 950
[UPDATE ... RETURNING] Node.js (pg 8.23.0): [ { id: '2', title: 'The Glass Orchard', published: 2019 } ]
[UPDATE ... RETURNING] Python (psycopg 3.3.6): [(2, 'The Glass Orchard', 2019)]
[UPDATE ... RETURNING] Go (pgx v5.11.0): [2 The Glass Orchard 2019]
[UPDATE ... RETURNING] Rust (sqlx 0.9.0): 2 The Glass Orchard 2019
[DELETE ... RETURNING] Node.js (pg 8.23.0): [ { id: '4', title: 'Unlinked Draft' } ]
[DELETE ... RETURNING] Python (psycopg 3.3.6): [(4, 'Unlinked Draft')]
[DELETE ... RETURNING] Go (pgx v5.11.0): [4 Unlinked Draft]
[DELETE ... RETURNING] Rust (sqlx 0.9.0): 4 Unlinked Draft
[DELETE in a transaction (then count(*) of sales)] Node.js (pg 8.23.0): { count: '4' }
[DELETE in a transaction (then count(*) of sales)] Python (psycopg 3.3.6): (4,)
[DELETE in a transaction (then count(*) of sales)] Go (pgx v5.11.0): 4
[DELETE in a transaction (then count(*) of sales)] Rust (sqlx 0.9.0): 4

เพื่อลบทุก row TRUNCATE คือเครื่องมือ: เร็วกว่า DELETE FROM บน table ใหญ่มากและเป็น transactional ใน PostgreSQL มันปฏิเสธเมื่อมี foreign key ชี้มาที่ table เว้นแต่คุณ truncate table ที่อ้างอิงด้วยหรือบอก CASCADE (ซึ่งล้าง table ที่คุณอาจไม่ได้ตั้งใจด้วย):

-- sql/truncate.sql
BEGIN;
TRUNCATE sales;
SELECT count(*) AS sales_rows FROM sales;
ROLLBACK;
SELECT count(*) AS sales_rows_after_rollback FROM sales;
BEGIN
TRUNCATE TABLE
sales_rows
------------
0
(1 row)
ROLLBACK
sales_rows_after_rollback
---------------------------
6
(1 row)
-- @expect-error 0A000
TRUNCATE books;
ERROR: cannot truncate a table referenced in a foreign key constraint
DETAIL: Table "sales" references "books".
HINT: Truncate table "sales" at the same time, or use TRUNCATE ... CASCADE.
-- sql/truncate-cascade.sql
BEGIN;
TRUNCATE books CASCADE;
SELECT (SELECT count(*) FROM books) AS books, (SELECT count(*) FROM sales) AS sales, (SELECT count(*) FROM authors) AS authors;
ROLLBACK;
BEGIN
NOTICE: truncate cascades to table "sales"
TRUNCATE TABLE
books | sales | authors
-------+-------+---------
0 | 0 | 3
(1 row)
ROLLBACK

PostgreSQL ไม่เคยเขียนทับ row ในที่เดิม UPDATE เขียน row เวอร์ชันใหม่และทิ้งเวอร์ชันเก่าเป็น dead tuple จนกว่า VACUUM จะเก็บกลับ และ DELETE แค่ทำเครื่องหมายว่า row ตาย หลักฐานบน 100,000 row: UPDATE ทั้ง table หนึ่งครั้งทำให้ไฟล์ heap ใหญ่ขึ้นเท่าตัว VACUUM ทำให้พื้นที่ตายนั้นใช้ซ้ำได้ (ไม่ทำให้ไฟล์เล็กลง) และ UPDATE ทั้ง table ครั้งที่สองก็พอดีในพื้นที่เดิม pg_stat_force_next_flush() เป็นตัวช่วยสำหรับการทดสอบที่เผยแพร่สถิติของ session นี้ทันที ปกติจะโผล่ราวหนึ่งวินาทีต่อมา:

-- sql/dead-tuples.sql
CREATE TABLE counters (id int PRIMARY KEY, n int NOT NULL);
INSERT INTO counters SELECT g, 0 FROM generate_series(1, 100000) g;
VACUUM (ANALYZE) counters;
SELECT pg_size_pretty(pg_relation_size('counters')) AS fresh_heap;
UPDATE counters SET n = n + 1;
SELECT pg_size_pretty(pg_relation_size('counters')) AS after_one_full_update;
SELECT pg_stat_force_next_flush();
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'counters';
VACUUM counters;
SELECT pg_stat_force_next_flush();
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'counters';
UPDATE counters SET n = n + 1;
SELECT pg_size_pretty(pg_relation_size('counters')) AS after_second_update_with_reuse;
CREATE TABLE
INSERT 0 100000
VACUUM
fresh_heap
------------
3544 kB
(1 row)
UPDATE 100000
after_one_full_update
-----------------------
7080 kB
(1 row)
pg_stat_force_next_flush
--------------------------
(1 row)
n_live_tup | n_dead_tup
------------+------------
200000 | 100000
(1 row)
VACUUM
pg_stat_force_next_flush
--------------------------
(1 row)
n_live_tup | n_dead_tup
------------+------------
100000 | 0
(1 row)
UPDATE 100000
after_second_update_with_reuse
--------------------------------
7080 kB
(1 row)

บท MVCC และบท vacuum จะกลับมาที่เรื่องนี้อย่างลึก ตอนนี้: DELETE ไม่คืนพื้นที่ดิสก์ให้ระบบปฏิบัติการ และ table ที่ update บ่อยต้อง vacuum สม่ำเสมอ (autovacuum ทำให้โดยค่าเริ่มต้น)

โจทย์คือ “sync books.copies_sold จากยอดขายภูมิภาค East” ผู้ช่วย AI เขียน UPDATE ... FROM และอ่านแล้วดูดีด้วย:

🐛 หา bug ในโค้ดนี้

-- migration: sync books.copies_sold from the East region sales
-- AI comment: "pull the East figures into the books table"
UPDATE books
SET copies_sold = s.copies
FROM sales s
WHERE s.region = 'East';
เฉลย

bug คือไม่มีเงื่อนไข join WHERE กรอง sales เหลือ row ของ East แต่ไม่เคยผูกยอดขายกับหนังสือของมัน หนังสือทุกเล่มจึงถูก join กับทุกยอดขายของ East และ PostgreSQL ใช้ผลจับคู่ตามอำเภอใจหนึ่งค่ากับแต่ละเล่ม มันรันโดยไม่มี error และแตะทุก row รวมถึงหนังสือที่ไม่มียอดขายเลย (UPDATE 4):

-- sql/update-from-bug.sql
BEGIN;
UPDATE books
SET copies_sold = s.copies
FROM sales s
WHERE s.region = 'East'
RETURNING books.id, books.title, old.copies_sold AS was, new.copies_sold AS now;
ROLLBACK;
BEGIN
id | title | was | now
----+-------------------+------+-----
1 | Quiet Harbors | 450 | 900
2 | The Glass Orchard | 1250 | 900
3 | Tidal Notes | 600 | 900
4 | Unlinked Draft | 0 | 900
(4 rows)
UPDATE 4
ROLLBACK

หนังสือทุกเล่มตอนนี้อ้างว่ามี 900 copy (ค่าของยอดขาย East แรกที่ join เลือกได้) วิธีแก้คือ join บน key และ aggregate ก่อน แต่ละเล่มจึงได้ค่าเดียว และแตะเฉพาะหนังสือที่มียอดขาย East:

-- sql/update-from-fixed.sql
BEGIN;
UPDATE books b
SET copies_sold = e.total
FROM (SELECT book_id, sum(copies) AS total FROM sales WHERE region = 'East' GROUP BY book_id) e
WHERE e.book_id = b.id
RETURNING b.id, b.title, old.copies_sold AS was, new.copies_sold AS now;
ROLLBACK;
BEGIN
id | title | was | now
----+-------------------+------+-----
1 | Quiet Harbors | 450 | 50
2 | The Glass Orchard | 1250 | 900
3 | Tidal Notes | 600 | 200
(3 rows)
UPDATE 3
ROLLBACK

RETURNING old.x, new.x คือสิ่งที่ทำให้ bug ชัด ใช้มัน (หรือ SELECT count(*) นำหน้า) กับทุกการแก้ข้อมูล

UPDATE และ DELETE คือ SELECT บวก action: WHERE (และ join FROM/USING) เลือกเซตของ row และ action ใช้กับเซตนั้น ถ้าเซตผิด action ก็ผิดแบบเงียบ ๆ ทุก workflow ที่ปลอดภัยจึงเกี่ยวกับการตรวจเซตก่อนลงมือ: SELECT count(*) ก่อน RETURNING หลัง และครอบด้วย transaction ภายใน MVCC หมายความว่าทั้งสอง statement ไม่ทำในที่เดิม ทั้งคู่สร้าง row เวอร์ชันตายให้ vacuum เก็บภายหลัง

  • ใส่ clause WHERE ให้ UPDATE และ DELETE เสมอ เว้นแต่คุณตั้งใจแตะทุก row จริง ๆ WHERE ที่ลืมเขียนทับหรือทำให้ทั้ง table ว่าง
  • ดูตัวอย่าง statement ที่ทำลายล้างก่อนด้วย SELECT count(*) ... WHERE ... ที่ตรงกัน เพื่อยืนยันว่าคุณกำลังจะกระทบกี่ row
  • ครอบการเปลี่ยนแปลงที่เสี่ยงหรือหลายขั้นด้วย transaction เพื่อ ROLLBACK ได้ถ้าผลผิด
  • ใช้ RETURNING (พร้อม old/new ใน PostgreSQL 18) เพื่อตรวจว่า row ไหนเปลี่ยนแน่ ๆ แทนที่จะเชื่อจำนวน row อย่างเดียว
  • ใน UPDATE ... FROM และ DELETE ... USING ใส่เงื่อนไข join ใน WHERE เสมอ
  • DELETE อาจล้มเหลวถ้า table อื่นอ้างอิง row ผ่าน foreign key (23503) ON DELETE CASCADE เปลี่ยนสิ่งนั้น ด้วยราคาคือการลบที่ใหญ่ขึ้น
  • ใช้ TRUNCATE (ไม่ใช่ DELETE) เพื่อล้างทั้ง table
ตัวเลือกBenefitCost
UPDATE/DELETE พร้อม WHERE เฉพาะปลอดภัย แตะเฉพาะ row ที่ตั้งใจต้องทำเงื่อนไขให้ถูกต้องสมบูรณ์ก่อนรัน
UPDATE/DELETE ที่ไม่มี WHEREเขียนเร็ว ไม่ต้องคิดเงื่อนไขโดนทุก row ทันที — หายนะถ้ารันผิด table หรือผิดสภาพแวดล้อม
ครอบด้วย transaction ที่ ROLLBACK ได้ทางออกฉุกเฉินก่อน COMMIT จริงต้องจำเปิด/ปิด transaction และตรวจผลทุกครั้ง
รันตรง ๆ นอก transactionเร็ว ไม่ต้องจัดการ transactionไม่มีปุ่มยกเลิกถ้าผลผิด
TRUNCATE แทน DELETE FROMทันที ไม่มีงานต่อ rowต้องใช้ lock แรง ถูกปฏิเสธเมื่อมี foreign key เว้นแต่ CASCADE
  • “DELETE คืนพื้นที่ดิสก์” มันแค่ทำเครื่องหมาย row ว่าตาย มีแต่ VACUUM ที่ทำให้พื้นที่ใช้ซ้ำได้ และมีเพียง page ว่างท้ายไฟล์ที่ถูกคืนให้ OS (heap ข้างบนไม่เล็กลง)
  • “UPDATE เปลี่ยน row ในที่เดิม” มันเขียนเวอร์ชันใหม่ (heap ใหญ่ขึ้นเท่าตัวหลัง update ทั้ง table หนึ่งครั้ง)
  • “UPDATE ... FROM ที่ไม่มี join เป็น syntax error” เป็น SQL ที่ถูกต้องและ update ทุก row ด้วยผลจับคู่ตามอำเภอใจ (พิสูจน์ข้างบน)
  • “จำนวน row พอจะรู้ว่าสำเร็จ” UPDATE 4 ดูปกติจนกว่าคุณคาดหวัง 1 ส่วน RETURNING แสดงว่า row ไหน
  • “TRUNCATE rollback ไม่ได้” ใน PostgreSQL ทำได้ ภายใน transaction
จะเกิดอะไรขึ้นถ้ารัน UPDATE books SET copies_sold = 0; โดยไม่มี WHERE?
clause ใดทำให้ UPDATE หรือ DELETE รายงานว่า row ไหนเปลี่ยนแน่ ๆ?
จะทำให้ DELETE ที่เสี่ยงย้อนกลับได้ขณะตรวจผลได้อย่างไร?
UPDATE books SET copies_sold = s.copies FROM sales s WHERE s.region = 'East' ไม่มีเงื่อนไข join ระหว่าง books กับ sales มันทำอะไร?
หลัง UPDATE ทั้ง table 100,000 row ไฟล์ table ใหญ่ขึ้นราวเท่าตัว เพราะอะไร?