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

การ insert row

การสร้างข้อมูลหมายถึงการเพิ่ม row และ statement สำหรับสิ่งนั้นคือ INSERT คุณระบุ table ลิสต์ column ที่คุณให้ค่า และใส่ค่า PostgreSQL เติมทุกอย่างที่คุณละไว้ — id ที่ถูกสร้าง, column ที่มี DEFAULT และ column ใดก็ตามที่อนุญาต NULL

บทเรียนนี้สร้างชุดข้อมูล authors, books และ sales ของคอร์สตั้งแต่ศูนย์ทีละ INSERT เมื่อจบคุณจะมี row ที่ตรงกับที่ทุกบทถัดไปเริ่มต้นด้วยพอดี ทุกอย่างที่แสดงคือ output จริงจาก PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) จากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว ตัวเลขเวลามาจาก Mac เครื่องนี้

-- 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
);

รูปแบบพื้นฐานที่สุดลิสต์ column แล้วตามด้วยค่าที่ตรงกัน เซิร์ฟเวอร์ตอบด้วย command tag INSERT 0 1: เลข 0 เป็น field object id แบบเก่า และ 1 คือจำนวน row ที่ insert

-- sql/insert-single.sql
INSERT INTO authors (name, born)
VALUES ('Mara Linde', 1968);
INSERT 0 1

สังเกตว่าแท็บ psql ใช้ค่า literal ขณะที่ทุก driver ใช้ placeholder — $1, $2 แบบ native ของ PostgreSQL หรือ %s สำหรับ psycopg — และส่งค่าจริงแยกต่างหาก ส่งข้อมูลผู้ใช้แบบนี้เสมอ ห้ามต่อค่าเข้าไปในสตริง SQL เอง (ส่วนสุดท้ายของบทเรียนนี้พิสูจน์เหตุผลด้วย payload จริง)

ใน pgAdmin: วางเวอร์ชัน psql ใน Query Tool แล้วรัน แผงข้อความจะรายงาน INSERT 0 1 หมายถึงเพิ่มหนึ่ง row

คุณลิสต์หลาย value tuple ใน statement เดียวได้ วิธีนี้เร็วกว่ารัน INSERT ทีละ row มาก เพราะเป็น round trip เดียวและ transaction เดียว (วัดด้านล่าง)

-- sql/insert-many.sql
INSERT INTO authors (name, born) VALUES
('Owen Pryce', 1975),
('Sela Vance', 1990);
SELECT id, name, born FROM authors ORDER BY id;
INSERT 0 2
id | name | born
----+------------+------
1 | Mara Linde | 1968
2 | Owen Pryce | 1975
3 | Sela Vance | 1990
(3 rows)

แต่ละ driver มีทางลัดสำหรับ bulk ของตัวเอง: psycopg มี executemany, pgx มี CopyFrom ที่เร็วมาก (โปรโตคอล COPY) และกับ sqlx คุณ unnest array เพื่อให้ statement เดียว insert หลาย row หนังสือสามเล่มถัดไปก็ insert แบบหลาย row เช่นกัน แต่ละเล่มมี author_id ซึ่งต้องตรงกับ author ที่มีอยู่เพราะ foreign key:

-- sql/insert-books.sql
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);

table books มี copies_sold int NOT NULL DEFAULT 0 ถ้าคุณละ column นั้น PostgreSQL ใช้ default คุณเขียน DEFAULT ระบุชัดเพื่อแสดงเจตนาได้ และ NULL เป็นค่าที่ถูกต้องสำหรับ column ที่ nullable เช่น author_id ทุก driver ทำเหมือนกัน: ละ column ออกจากรายการ column

-- sql/insert-default.sql
INSERT INTO books (title, author_id, published, copies_sold)
VALUES ('Unlinked Draft', NULL, 2020, DEFAULT);
SELECT id, title, author_id, copies_sold FROM books ORDER BY id;
INSERT 0 1
id | title | author_id | copies_sold
----+-------------------+-----------+-------------
1 | Quiet Harbors | 1 | 450
2 | The Glass Orchard | 1 | 1250
3 | Tidal Notes | 2 | 600
4 | Unlinked Draft | | 0
(4 rows)

copies_sold ได้ 0 และ author_id เป็น NULL และ identity ให้ row ใหม่ id 4 การละ column ทั้งหมดเป็นวิธีปกติ การเขียน DEFAULT สะดวกเมื่อคุณกรอกหลาย row และอยากให้ทุกตำแหน่งชัดเจน

column id เป็น GENERATED ALWAYS AS IDENTITY PostgreSQL จึงกำหนดให้ เพื่อรู้ค่าโดยไม่ต้อง query ที่สอง ให้ต่อท้ายด้วย RETURNING บล็อกด้านล่างรันใน transaction ที่ถูก rollback จึงไม่มี author เกินค้างอยู่ (แต่ค่า identity ถูกใช้ไปแล้ว: sequence ไม่เคย rollback ดังที่บท constraints แสดง)

-- sql/insert-returning.sql
BEGIN;
INSERT INTO authors (name, born)
VALUES ('Temp Author', 2000)
RETURNING id;
ROLLBACK;
BEGIN
id
----
4
(1 row)
INSERT 0 1
ROLLBACK

statement ทั้ง insert row และส่ง id ใหม่กลับใน round trip เดียว ที่นี่ id ถัดไปคือ 4 เพราะมี author 1-3 อยู่แล้ว RETURNING ใช้กับ insert หลาย row ได้เช่นกันและคืนหนึ่ง row ต่อหนึ่ง row ที่ insert ตามลำดับที่ insert นี่คือวิธีที่ row ของ sales เข้าไป:

-- sql/insert-returning-many.sql
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)
RETURNING id, book_id, region, copies;
id | book_id | region | copies
----+---------+--------+--------
1 | 2 | East | 900
2 | 3 | West | 400
3 | 1 | West | 400
4 | 3 | East | 200
5 | 2 | West | 150
6 | 1 | East | 50
(6 rows)
INSERT 0 6

คุณคืน column ใดก็ได้ตามต้องการ — RETURNING id, name หรือแม้แต่ RETURNING * เพื่อรับ row ใหม่ทั้งแถว รวมถึง default ที่เซิร์ฟเวอร์เติมให้ (แต่ละ driver ข้างบนรันหนึ่งครั้งกับฐานข้อมูลชั่วคราว ผลลัพธ์ต่อไปนี้)

$ (each program run once against a scratch database; `authors` emptied before every run, then `SELECT string_agg(name, ', ' ORDER BY id) FROM authors`)
single row Node.js (pg 8.23.0) -> Mara Linde
single row Python (psycopg 3.3.6) -> Mara Linde
single row Go (pgx v5.11.0) -> Mara Linde
single row Rust (sqlx 0.9.0) -> Mara Linde
two rows Node.js (pg 8.23.0) -> Owen Pryce, Sela Vance
two rows Python (psycopg 3.3.6) -> Owen Pryce, Sela Vance
two rows Go (pgx v5.11.0) -> Owen Pryce, Sela Vance
two rows Rust (sqlx 0.9.0) -> Owen Pryce, Sela Vance
RETURNING id Node.js (pg 8.23.0) -> 1
RETURNING id Python (psycopg 3.3.6) -> 1
RETURNING id Go (pgx v5.11.0) -> 1
RETURNING id Rust (sqlx 0.9.0) -> 1

insert ล้มเหลวได้สี่แบบที่คุณจะเจอจริง แต่ละแบบเป็น SQLSTATE class และข้อความที่ต่างกัน ของจริง:

  • ส่งค่าให้ identity column แบบ GENERATED ALWAYS (428C9) OVERRIDING SYSTEM VALUE คือทางหนีที่ตั้งใจใช้:
-- @expect-error 428C9
INSERT INTO authors (id, name, born) VALUES (10, 'Impostor', 1980);
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.
  • ละ column NOT NULL ที่ไม่มี default (23502):
-- @expect-error 23502
INSERT INTO sales (book_id, copies) VALUES (1, 10);
ERROR: null value in column "region" of relation "sales" violates not-null constraint
DETAIL: Failing row contains (7, 1, null, 10).
  • ชี้ไปที่ row ที่ไม่มีอยู่ (23503):
-- @expect-error 23503
INSERT INTO sales (book_id, region, copies) VALUES (99, 'East', 10);
ERROR: insert or update on table "sales" violates foreign key constraint "sales_book_id_fkey"
DETAIL: Key (book_id)=(99) is not present in table "books".

แหล่งของ insert ไม่ต้องเป็นรายการ literal เสมอไป INSERT ... SELECT คัดลอก row ที่ query ใดก็ได้สร้างขึ้น และ DEFAULT VALUES insert row ที่ทุก column ใช้ค่า default:

-- sql/insert-select.sql
CREATE TABLE top_books (title text NOT NULL, copies_sold int NOT NULL);
INSERT INTO top_books
SELECT title, copies_sold FROM books WHERE copies_sold > 500 ORDER BY copies_sold DESC;
CREATE TABLE pings (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, at timestamptz NOT NULL DEFAULT now());
INSERT INTO pings DEFAULT VALUES RETURNING id;
SELECT * FROM top_books;
CREATE TABLE
INSERT 0 2
CREATE TABLE
id
----
1
(1 row)
INSERT 0 1
title | copies_sold
-------------------+-------------
The Glass Orchard | 1250
Tidal Notes | 600
(2 rows)

ข้ออ้างว่า “insert หลาย row และ COPY เร็วกว่าลูปของ insert เดี่ยวมาก” ตรวจสอบได้ง่าย 50,000 row เดียวกัน สี่วิธี ลงสี่ table วัดเวลาใน psql session เดียว สองวิธีแรกรัน INSERT แยกกัน 50,000 statement จากฝั่ง client (\gexec) นี่คือผ่าน Unix socket ในเครื่อง ต้นทุนเครือข่ายที่คุณจะจ่ายบน production จึงยังไม่รวมอยู่ด้วยซ้ำ:

-- sql/load-timing.sql
CREATE TABLE load_single (id int, label text);
CREATE TABLE load_tx (id int, label text);
CREATE TABLE load_multi (id int, label text);
CREATE TABLE load_copy (id int, label text);
-- 1) one INSERT statement per row, each its own transaction (autocommit)
SELECT clock_timestamp() AS t0 \gset
\o /dev/null
SELECT format('INSERT INTO load_single VALUES (%s, %L);', g, 'row-' || g) FROM generate_series(1, 50000) g \gexec
\o
SELECT round(extract(epoch FROM clock_timestamp() - :'t0') * 1000) AS single_row_autocommit_ms;
-- 2) the same statements inside ONE transaction
SELECT clock_timestamp() AS t0 \gset
BEGIN;
\o /dev/null
SELECT format('INSERT INTO load_tx VALUES (%s, %L);', g, 'row-' || g) FROM generate_series(1, 50000) g \gexec
\o
COMMIT;
SELECT round(extract(epoch FROM clock_timestamp() - :'t0') * 1000) AS single_row_one_tx_ms;
-- 3) one multi-row statement
SELECT clock_timestamp() AS t0 \gset
INSERT INTO load_multi SELECT g, 'row-' || g FROM generate_series(1, 50000) g;
SELECT round(extract(epoch FROM clock_timestamp() - :'t0') * 1000) AS one_statement_ms;
-- 4) COPY from a file (\copy: the file is on the client side)
\copy (SELECT g, 'row-' || g FROM generate_series(1, 50000) g) TO '/tmp/load.csv' WITH (FORMAT csv)
SELECT clock_timestamp() AS t0 \gset
\copy load_copy FROM '/tmp/load.csv' WITH (FORMAT csv)
SELECT round(extract(epoch FROM clock_timestamp() - :'t0') * 1000) AS copy_ms;
SELECT (SELECT count(*) FROM load_single) AS single_rows, (SELECT count(*) FROM load_tx) AS tx_rows,
(SELECT count(*) FROM load_multi) AS multi_rows, (SELECT count(*) FROM load_copy) AS copy_rows;
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
single_row_autocommit_ms
--------------------------
6607
(1 row)
BEGIN
COMMIT
single_row_one_tx_ms
----------------------
3400
(1 row)
INSERT 0 50000
one_statement_ms
------------------
34
(1 row)
COPY 50000
COPY 50000
copy_ms
---------
15
(1 row)
single_rows | tx_rows | multi_rows | copy_rows
-------------+---------+------------+-----------
50000 | 50000 | 50000 | 50000
(1 row)

บน Mac เครื่องนี้: ราว 6.6 วินาทีเมื่อแต่ละ row เป็น transaction ของตัวเอง ราว 3.4 วินาทีเมื่อ statement เดียวกันอยู่ใน transaction เดียว (ต่างกันที่การ commit 50,000 ครั้ง) และราว 34 ms สำหรับ statement หลาย row เดียว หรือ 15 ms สำหรับ COPY นั่นต่างกันหลายร้อยเท่าระหว่างลูปแบบไร้เดียงสากับแบบ batch ก่อนบวก latency เครือข่ายต่อ statement ใช้ INSERT หลาย row เดียวสำหรับ batch เล็ก (statement หนึ่งส่ง parameter ที่ bind ได้สูงสุด 65,535 ตัว ดู protocol message formats) และ COPY สำหรับโหลดขนาดใหญ่

ผู้ช่วย AI ที่ถูกขอ “insert เร็ว ๆ สำหรับฟอร์มสมัครสมาชิก” เขียน helper มันใช้ได้ในทุก demo รวมถึงชื่อที่ตัวอักษรปกติ:

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

// signup.js (AI comment: "quick insert for the signup form")
async function addAuthor(name, born) {
await pool.query(
`INSERT INTO authors (name, born) VALUES ('${name}', ${born})`
);
}
เฉลย

bug คือค่าถูกแปะลงในข้อความ SQL ด้วย template string กับ pg query ที่ไม่มี parameter ถูกส่งเป็น simple query ซึ่งมีหลาย statement ได้ name ที่ถูกออกแบบมาจึงปิด INSERT แล้วต่อ statement ของตัวเอง พิสูจน์ด้วยโปรแกรมจริงกับฐานข้อมูลชั่วคราว (table sales มี 6 row) ก่อนอื่น สิ่งที่เซิร์ฟเวอร์ได้รับจริงสำหรับชื่ออันตราย สร้างใน SQL ด้วย format:

-- sql/injection-built-sql.sql
SELECT format($f$INSERT INTO authors (name, born) VALUES ('%s', %s)$f$,
$p$x', 1); DELETE FROM sales; --$p$, 1990) AS what_the_server_receives;
what_the_server_receives
---------------------------------------------------------------------------------
INSERT INTO authors (name, born) VALUES ('x', 1); DELETE FROM sales; --', 1990)
(1 row)

statement แรก insert author ไร้พิษภัย x statement ที่สองลบทุก sale และ -- คอมเมนต์ส่วนที่เหลือ ทีนี้โปรแกรมจริง: แม้ชื่อที่ถูกต้องที่มีเครื่องหมายอะพอสโทรฟีก็ทำให้พัง (42601) ชื่ออันตรายทำให้ sales ว่าง และเวอร์ชัน placeholder เก็บข้อความอันตรายเดียวกันเป็นข้อมูลธรรมดาและไม่แตะ sales:

$ node inj.mjs # pg 8.23.0, scratch database with 6 rows in sales
sales rows before: 6
O'Brien -> 42601 syntax error at or near "Brien"
sales rows after unsafe insert: 0
sales rows restored: 6
sales rows after safe insert: 6
[
{ id: '1', name: 'x' },
{ id: '2', name: "x', 1); DELETE FROM sales; --" },
{ id: '3', name: "O'Brien" }
]

วิธีแก้คือส่งค่าแยกจาก SQL เซิร์ฟเวอร์จึงไม่เคย parse ค่าเป็น SQL:

async function addAuthor(name, born) {
await pool.query(
'INSERT INTO authors (name, born) VALUES ($1, $2)',
[name, born],
);
}

ใน psql แนวคิดเดียวกันคือ PREPARE / EXECUTE (driver ใช้สิ่งเทียบเท่าระดับโปรโตคอล) สตริงอันตรายถูกเก็บตามตัวอักษร และ sales ยังมี 6 row (rollback เพื่อรักษาชุดข้อมูลให้ครบ):

-- sql/injection-safe.sql
BEGIN;
PREPARE add_author (text, int) AS
INSERT INTO authors (name, born) VALUES ($1, $2);
EXECUTE add_author($p$x', 1); DELETE FROM sales; --$p$, 1990);
SELECT name FROM authors WHERE born = 1990 ORDER BY id;
SELECT count(*) AS sales_rows FROM sales;
ROLLBACK;
DEALLOCATE add_author;
BEGIN
PREPARE
INSERT 0 1
name
-------------------------------
Sela Vance
x', 1); DELETE FROM sales; --
(2 rows)
sales_rows
------------
6
(1 row)
ROLLBACK
DEALLOCATE

INSERT คือคำขอไปยังเซิร์ฟเวอร์ และทุกอย่างที่คุณละไว้ schema เป็นผู้ตัดสิน: DEFAULT, การสร้าง identity หรือ NULL คำสั่งที่คุณเขียนเลือก โปรไฟล์ต้นทุน: ทุก statement คือ round trip และทุก transaction คือ commit การ batch ไม่ว่าใน statement เดียวหรือ transaction เดียว เปลี่ยนสองตัวเลขนั้น และนั่นคือเรื่องความเร็วทั้งหมด placeholder แยก โค้ด ออกจาก ข้อมูล เซิร์ฟเวอร์จึงไม่มีวันสับสนสองอย่างนี้

  • ส่งค่าผ่าน placeholder ของ driver ($1, %s) เสมอ การสร้าง SQL ด้วยการต่อสตริงเชื้อเชิญ SQL injection และพังกับเครื่องหมายคำพูดหรืออักขระพิเศษ
  • กับ primary key แบบ IDENTITY (หรือ serial) อย่าใส่ id เอง ให้ PostgreSQL สร้างแล้วอ่านกลับด้วย RETURNING
  • insert หลาย row คือ statement เดียวและ round trip เดียว COPY เร็วกว่าอีกสำหรับโหลดใหญ่ (วัดข้างบน)
  • insert ที่ล้มเหลวและที่ถูก rollback ก็ยังใช้ค่า identity ไป id จึงมีช่องว่าง
  • column ที่ไม่มีค่าและไม่มี default ต้องอนุญาต NULL ไม่เช่นนั้น insert ล้มเหลวด้วย not-null violation
ตัวเลือกBenefitCost
RETURNING หลัง INSERTรับค่าที่ถูกสร้าง (เช่น id) กลับใน round trip เดียว ไม่มี race conditionมี syntax ให้จำเพิ่ม และบาง driver ต้องอ่านผลเป็น query แทน exec ธรรมดา
SELECT แยกทีหลังเรียบง่ายและผู้เริ่มต้นตามได้ง่ายเพิ่ม round trip และมี race condition ถ้ามี insert อื่นแทรกเข้ามา
INSERT หลาย row ใน statement เดียวเร็ว — round trip เดียว (34 ms สำหรับ 50,000 row ข้างบน)statement ยาวขึ้น และ row เสียหนึ่งแถวทำให้ทั้ง statement ล้มได้
COPY / \copyทางลัด bulk ที่เร็วที่สุด (15 ms สำหรับ 50,000 row ข้างบน)ต้องมีไฟล์หรือ stream แบบ CSV/text จัดการ error ได้ยืดหยุ่นน้อยกว่า
ลูป insert ทีละ rowอ่านง่าย ทีละขั้น6.6 วินาทีสำหรับ 50,000 row เดียวกัน (autocommit), 3.4 วินาทีใน transaction เดียว
  • “การ batch ช่วยเฉพาะบนเครือข่ายช้า” มันช่วยหลายร้อยเท่าแม้ผ่าน local socket เพราะแต่ละ statement ต้องจ่าย commit ด้วย (6.6 วินาที กับ 3.4 วินาที กับ 34 ms ข้างบน)
  • “escape เครื่องหมายคำพูดก็พอจะปลอดภัย” placeholder คือทางแก้ การ escape ด้วยมือคือที่มาของ bug injection (ความล้มเหลวของ O'Brien ข้างบนเป็นเวอร์ชันที่เป็นมิตร)
  • “RETURNING ต้องใช้ query ที่สอง” มันเป็นส่วนของ statement เดียวกัน แม้กับหลาย row
  • “insert ที่ rollback ไม่ทิ้งร่องรอย” row หายไป แต่ค่า identity ถูกใช้ไป (temp author ใช้ 4)
  • “DEFAULT กับการละ column ต่างกัน” เหมือนกัน DEFAULT แค่ทำให้รายการ column ครบชัดเจน
คุณอ่าน id ที่เซิร์ฟเวอร์สร้างทันทีหลัง insert ได้อย่างไร?
column หนึ่งมี DEFAULT 0 และเป็น NOT NULL จะเกิดอะไรขึ้นถ้าละมันจากรายการ column?
ทำไมจึงส่งค่าเป็น placeholder ($1, %s) แทนการต่อเข้าไปในสตริง SQL?
คุณ insert ลง table ที่ id เป็น GENERATED ALWAYS AS IDENTITY และระบุ id = 10 จะเกิดอะไรขึ้น?
วิธีใดโหลด 50,000 row ได้เร็วที่สุดในการวัดของบทเรียน?