Sửa đổi & Nạp dữ liệu hàng loạtModifying & Bulk-Loading Data
Thuộc bộ kiến thức PostgreSQL DBA Roadmap.
Tổng quan
Việc đọc dữ liệu (query) thường chiếm phần lớn sự chú ý trong các tài liệu SQL, nhưng một DBA dành thời gian không kém để nghĩ về cách dữ liệu được ghi. Mỗi câu lệnh INSERT, UPDATE, DELETE đều phải đi qua toàn bộ cơ chế transaction của PostgreSQL — MVCC snapshot, ghi WAL (write-ahead log), cập nhật index, kích hoạt trigger — và chi phí của cơ chế này thay đổi rất khác nhau tùy vào việc bạn đang xử lý một dòng hay mười triệu dòng.
Bài viết này bao quát các câu lệnh DML thường ngày và những mệnh đề giúp chúng mạnh hơn (RETURNING, ON CONFLICT), những điểm khác biệt dễ gây nhầm lẫn giữa TRUNCATE và DELETE, và — phần quan trọng nhất về mặt vận hành — cách nạp khối lượng dữ liệu lớn một cách hiệu quả bằng COPY cùng một loạt kỹ thuật có thể biến một tác vụ import chạy qua đêm thành một tác vụ chỉ mất năm phút.
Kiến thức nền tảng
INSERT, UPDATE, DELETE
Ba câu lệnh DML cơ bản trông có vẻ đơn giản:
-- INSERT một dòng
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (101, 55, 3, 'pending');
-- INSERT nhiều dòng trong một câu lệnh (nhanh hơn nhiều so với mỗi dòng một INSERT)
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES
(101, 55, 3, 'pending'),
(102, 12, 1, 'pending'),
(103, 78, 5, 'pending');
-- UPDATE với mệnh đề WHERE
UPDATE orders
SET status = 'shipped', shipped_at = now()
WHERE id = 42;
-- UPDATE kết hợp JOIN với bảng khác
UPDATE orders o
SET status = 'cancelled'
FROM customers c
WHERE o.customer_id = c.id
AND c.is_blocked = true
AND o.status = 'pending';
-- DELETE với mệnh đề WHERE
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < now() - interval '1 year';
Một vài điều nên nắm rõ ngay từ đầu:
- Trong cơ chế MVCC của PostgreSQL, mỗi
UPDATEthực chất là một cặp xóa-rồi-thêm ở tầng storage: phiên bản dòng cũ được đánh dấu không còn hiển thị, còn một phiên bản dòng mới được ghi vào (trừ khi áp dụng được HOT — Heap-Only Tuple update). Đây là lý do các bảng bị update nhiều cần chạyVACUUMđịnh kỳ để dọn các dead tuple. INSERTnhiều dòng (ví dụ thứ hai ở trên) rẻ hơn rất nhiều so với cùng số lượng dòng nhưng chia thành nhiều câuINSERTđơn lẻ, vì nó khấu hao chi phí parse, plan, và flush WAL trên nhiều dòng trong một round trip.- Câu lệnh
UPDATE/DELETEthiếuWHEREsẽ tác động lên toàn bộ bảng — luôn kiểm tra kỹ mệnh đềWHERE, tốt nhất là chạy thửSELECTtương ứng trước để xem những dòng nào sẽ bị ảnh hưởng.
RETURNING — lấy lại các dòng bị ảnh hưởng miễn phí
Một tính năng mà nhiều kỹ sư đến từ các hệ quản trị cơ sở dữ liệu khác không biết PostgreSQL có: bất kỳ câu lệnh INSERT, UPDATE, hay DELETE nào cũng có thể kết thúc bằng mệnh đề RETURNING, trả về các dòng bị ảnh hưởng y hệt như thể bạn vừa chạy một SELECT — nhưng không cần round trip thứ hai hay một câu query riêng biệt.
-- Lấy lại id được sinh ra và timestamp từ một INSERT
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (101, 55, 3, 'pending')
RETURNING id, created_at;
-- id | created_at
-- -----+-------------------------------
-- 871 | 2026-07-19 09:12:44.128374+00
-- Lấy lại những dòng mà một UPDATE thực sự đã tác động
UPDATE orders
SET status = 'shipped', shipped_at = now()
WHERE status = 'pending' AND created_at < now() - interval '2 days'
RETURNING id, customer_id, status;
-- Lấy lại những dòng bạn vừa xóa, ví dụ để ghi log hoặc chuyển sang nơi khác
DELETE FROM sessions
WHERE expires_at < now()
RETURNING session_id, user_id;
-- RETURNING * trả về mọi cột
INSERT INTO audit_log (event, payload) VALUES ('login', '{"ip":"10.0.0.5"}')
RETURNING *;
RETURNING không chỉ là một tiện ích nhỏ. Nếu không có nó, pattern phổ biến “insert và lấy ID mới” thường phải dùng một câu SELECT currval(...)/lastval() riêng, hoặc một round trip đọc lại chính dòng vừa ghi — cả hai đều chậm hơn, và trong môi trường concurrent còn tiềm ẩn race condition (insert của session khác có thể làm thay đổi giá trị lastval() trả về nếu bạn không cẩn thận về phạm vi session). RETURNING đảm bảo bạn nhận lại chính xác các dòng mà câu lệnh của mình đã tác động, trong cùng transaction, chỉ với một round trip qua mạng. Nó còn kết hợp được với INSERT ... SELECT và CTE, cho phép các pattern như “xóa dòng khỏi một bảng và chèn các dòng đã xóa đó vào bảng archive” chỉ trong một câu lệnh duy nhất, thông qua data-modifying CTE:
WITH moved AS (
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < now() - interval '1 year'
RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM moved;
TRUNCATE vs DELETE
Cả hai đều xóa dòng, nhưng chúng được triển khai hoàn toàn khác nhau và những khác biệt đó có ý nghĩa quan trọng về mặt vận hành.
| Khía cạnh | DELETE FROM table | TRUNCATE TABLE |
|---|---|---|
Lọc dòng theo WHERE | Có, có thể xóa một tập con | Không, luôn xóa toàn bộ dòng |
| Tốc độ trên bảng lớn | Chậm — quét và đánh dấu từng dòng là dead (MVCC), sinh một bản ghi WAL cho mỗi dòng (hoặc mỗi page) | Nhanh — giải phóng toàn bộ data file, chỉ sinh vài bản ghi WAL bất kể số dòng |
| Trigger | Kích hoạt trigger ON DELETE ở cấp dòng, một lần cho mỗi dòng | Trigger cấp dòng không được kích hoạt; chỉ trigger cấp statement dành riêng cho TRUNCATE mới chạy |
| Reset sequence/identity | Không — sequence giữ nguyên giá trị hiện tại | Có, nếu chỉ định RESTART IDENTITY (TRUNCATE table RESTART IDENTITY), sequence của SERIAL/IDENTITY được reset về giá trị khởi đầu |
| Giải phóng không gian ngay lập tức | Không — cần VACUUM (hoặc autovacuum) sau đó để dọn không gian; các slot dòng chỉ được đánh dấu dead | Có — không gian file được trả lại cho OS ngay lập tức |
| An toàn với transaction/rollback | Có, hoàn toàn transactional và tuân theo MVCC (transaction khác vẫn thấy dòng cũ cho đến khi commit) | Có, transactional trong PostgreSQL (khác với một số hệ quản trị khác) — ROLLBACK sẽ hoàn tác được — nhưng nó lấy khóa ACCESS EXCLUSIVE trên toàn bảng |
| Chạy trong transaction dài với reader đồng thời | Có, ít chặn hơn (khóa ở mức dòng/tuple) | Lấy khóa mạnh ở mức bảng, chặn mọi đọc/ghi đồng thời trên bảng đó cho đến khi commit |
| Foreign key | Hoạt động bình thường, tuân theo hành động ON DELETE | Thất bại (hoặc cần CASCADE) nếu có bảng khác tham chiếu foreign key đến bảng này, trừ khi truncate cùng lúc: TRUNCATE a, b CASCADE; |
| Trả về dòng bị ảnh hưởng | Hỗ trợ RETURNING | Không hỗ trợ RETURNING |
-- DELETE: có chọn lọc, trigger được kích hoạt, chậm trên bảng lớn, cần VACUUM sau đó
DELETE FROM sessions WHERE expires_at < now();
-- TRUNCATE: xóa sạch cả bảng ngay lập tức, reset identity sequence
TRUNCATE TABLE staging_import RESTART IDENTITY;
-- Truncate nhiều bảng liên quan cùng lúc, theo phụ thuộc FK
TRUNCATE TABLE orders, order_items CASCADE;
Nguyên tắc chung: dùng TRUNCATE để xóa sạch bảng staging giữa các batch ETL, hoặc xóa toàn bộ một bảng mà bạn kiểm soát hoàn toàn (không cần xóa một phần); dùng DELETE bất cứ khi nào cần WHERE, cần kích hoạt trigger cấp dòng, hoặc chỉ xóa một phần nhỏ-đến-vừa phải dòng trong một bảng đang có traffic đồng thời.
Khái niệm chính
Upsert: INSERT … ON CONFLICT
Bài toán kinh điển “insert hoặc update nếu đã tồn tại” theo cách truyền thống đòi hỏi ứng dụng phải SELECT trước, rồi quyết định INSERT hay UPDATE. Pattern này có race condition: giữa SELECT và INSERT/UPDATE tiếp theo, một transaction khác có thể chèn cùng dòng đó, khiến bạn nhận lỗi duplicate-key hoặc âm thầm ghi đè dữ liệu sai.
PostgreSQL giải quyết vấn đề này một cách nguyên tử (atomic), ở cấp độ dòng, bằng INSERT ... ON CONFLICT:
CREATE TABLE product_inventory (
sku text PRIMARY KEY,
quantity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Không làm gì nếu dòng đã tồn tại (bỏ qua duplicate một cách âm thầm)
INSERT INTO product_inventory (sku, quantity)
VALUES ('WIDGET-001', 50)
ON CONFLICT (sku) DO NOTHING;
-- Update dòng đã tồn tại nếu phát hiện conflict (upsert thực sự)
INSERT INTO product_inventory (sku, quantity, updated_at)
VALUES ('WIDGET-001', 50, now())
ON CONFLICT (sku)
DO UPDATE SET
quantity = product_inventory.quantity + EXCLUDED.quantity,
updated_at = now();
Một vài điểm cần làm rõ:
- Conflict target:
ON CONFLICT (sku)chỉ định (các) cột phải có unique constraint hoặc exclusion constraint (hoặc unique index tương ứng) để PostgreSQL biết bắt vi phạm constraint nào. Bạn cũng có thể chỉ định trực tiếp một constraint theo tên:ON CONFLICT ON CONSTRAINT product_inventory_pkey DO UPDATE .... Nếu không có conflict target hợp lệ khớp với một unique/exclusion constraint đang tồn tại, PostgreSQL sẽ báo lỗi thay vì tự đoán. EXCLUDED: bên trong mệnh đềDO UPDATE SET, pseudo-tableEXCLUDEDtham chiếu đến dòng lẽ ra sẽ được insert nếu không có conflict — tức là các giá trị mới từ mệnh đềVALUEScủa bạn. Điều này cho phép bạn tham chiếu “giá trị tôi đang cố insert” so vớiproduct_inventory.quantitylà “giá trị đang được lưu trữ hiện tại.”- Tính nguyên tử (atomicity): toàn bộ chuỗi kiểm-tra-và-thực-thi diễn ra trong một câu lệnh duy nhất, được bảo vệ bởi cùng loại khóa mà một
INSERTthông thường sẽ lấy. Hai session đồng thời upsert cùng một key sẽ được serialize trên khóa của dòng đó thay vì race nhau — một session thắng phần insert, session kia thấy conflict và áp dụngDO UPDATEcủa mình, không có lỗi duplicate-key và không mất update nào. - Bạn có thể thêm mệnh đề
WHEREvàoDO UPDATEđể làm nó có điều kiện:ON CONFLICT (sku) DO UPDATE SET quantity = EXCLUDED.quantity WHERE product_inventory.updated_at < EXCLUDED.updated_at(chỉ áp dụng update nếu dòng mới đến mới hơn — pattern “last write wins theo timestamp” thường gặp). ON CONFLICTcũng kết hợp được vớiRETURNING, cho phép biết trong một round trip liệu một dòng đã được insert hay update, bằng cách so sánh (ví dụ) timestamp trả về.
-- Bulk upsert từ danh sách VALUES — pattern rất thường gặp khi đồng bộ dữ liệu từ hệ thống ngoài
INSERT INTO product_inventory (sku, quantity, updated_at)
VALUES
('WIDGET-001', 12, now()),
('WIDGET-002', 40, now()),
('GADGET-010', 5, now())
ON CONFLICT (sku)
DO UPDATE SET
quantity = EXCLUDED.quantity,
updated_at = EXCLUDED.updated_at;
Vì sao INSERT từng dòng không mở rộng được cho bulk loading
Nạp một tập dữ liệu lớn — ví dụ năm triệu dòng từ file CSV export — bằng cách chạy mỗi dòng một câu INSERT là một trong những lỗi hiệu năng phổ biến nhất. Chi phí đến từ nhiều nguồn cộng dồn trên từng dòng:
- Chi phí trên mỗi statement: mỗi câu lệnh được parse, plan, và thực thi độc lập. Ngay cả với prepared statement, vẫn có độ trễ round-trip mạng trên mỗi lần gọi nếu client gửi từng câu một thay vì pipeline/batch.
- Ghi WAL: mỗi statement được commit (hoặc tệ hơn, mỗi statement nếu bạn không batch transaction) sinh ra một lần flush WAL riêng nếu bạn commit sau mỗi lệnh, và
fsyncxuống đĩa là một trong những thao tác tốn kém nhất mà cơ sở dữ liệu thực hiện. MộtINSERTautocommit cho mỗi dòng nghĩa là có thể hàng triệu lần fsync. - Bảo trì index trên từng dòng: mọi index trên bảng phải được cập nhật ở mỗi lần insert. Với năm index và năm triệu dòng, đó là 25 triệu lần chèn entry vào index, mỗi lần có thể kích hoạt page split trong B-tree, và mỗi lần sinh ra bản ghi WAL riêng.
- Chi phí trigger: bất kỳ trigger
BEFORE/AFTER INSERTnào cũng kích hoạt một lần cho mỗi dòng, nhân chi phí công việc của nó lên theo số dòng. - Chi phí transaction: nếu mỗi
INSERTlà một transaction ngầm riêng (autocommit), bạn cũng phải trả chi phí commit transaction (bao gồm flush WAL, trừ khisynchronous_commitđược nới lỏng) năm triệu lần thay vì một lần.
Giải pháp không phải là “insert nhanh hơn” — mà là “dùng một con đường được thiết kế cho bulk, batch hóa toàn bộ những chi phí trên.”
COPY để import/export
COPY là câu lệnh truyền dữ liệu hàng loạt chuyên dụng của PostgreSQL. Nó đọc hoặc ghi nhiều dòng trong một thao tác, streaming dữ liệu trực tiếp giữa client/server và bảng, và nhanh hơn rất nhiều so với INSERT từng dòng khi nạp dữ liệu lớn.
-- COPY FROM: nạp hàng loạt file CSV vào bảng (chạy phía server)
COPY orders (customer_id, product_id, quantity, status, created_at)
FROM '/var/lib/postgresql/import/orders.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');
-- COPY TO: export hàng loạt một bảng (hoặc query) ra file CSV (chạy phía server)
COPY (SELECT * FROM orders WHERE created_at >= '2026-01-01')
TO '/var/lib/postgresql/export/orders_2026.csv'
WITH (FORMAT csv, HEADER true);
COPY (không có dấu gạch chéo ngược) được thực thi hoàn toàn ở phía server: đường dẫn file được đọc/ghi bởi chính tiến trình server PostgreSQL, trên hệ thống file của server. Điều đó nghĩa là file phải đã tồn tại (hoặc có thể ghi được) trên máy đang chạy postgres, và mặc định chỉ superuser hoặc role có quyền pg_read_server_files/pg_write_server_files mới được dùng nó, vì nó cho phép đọc/ghi file bất kỳ với tư cách user hệ điều hành postgres.
\copy là biến thể chạy phía client psql — một meta-command của psql, không phải SQL thật — đọc file từ nơi mà chính psql đang chạy (laptop của bạn, một bastion host, một CI runner) và stream dữ liệu đến server qua kết nối client thông thường. Đây là phiên bản mà hầu hết mọi người thực sự dùng hàng ngày, đặc biệt khi kết nối tới một cơ sở dữ liệu từ xa/được quản lý (ví dụ RDS) nơi bạn không có quyền truy cập hệ thống file server.
# \copy chạy từ máy local của bạn, không cần quyền truy cập filesystem của server
psql "host=db.example.com dbname=app user=admin" -c "\copy orders (customer_id, product_id, quantity, status, created_at) FROM 'orders.csv' WITH (FORMAT csv, HEADER true)"
# Hoặc dùng tương tác bên trong psql:
psql "host=db.example.com dbname=app user=admin"
app=> \copy orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
app=> \copy (SELECT * FROM orders WHERE status = 'shipped') TO 'shipped_orders.csv' WITH (FORMAT csv, HEADER true)
COPY | \copy | |
|---|---|---|
| Thực thi ở đâu | Server (tiến trình postgres) | Client (psql) |
| Vị trí file | Hệ thống file của server | Máy local đang chạy psql |
| Quyền cần có | Superuser hoặc pg_read_server_files/pg_write_server_files | Bất kỳ quyền bảng thông thường nào mà role đang kết nối có |
| Hoạt động với DB quản lý/từ xa không có shell access | Không | Có |
| Giao thức bên dưới | Câu lệnh SQL native, một phần của chế độ COPY trong wire protocol | Bọc cùng giao thức COPY, chỉ chuyển tiếp byte qua kết nối client |
COPY nhanh hơn INSERT từng dòng đáng kể vì:
- Đó là một statement duy nhất, một transaction duy nhất (trừ khi bạn tự chia nhỏ) — một lần commit, một lần flush WAL ở cuối thay vì mỗi dòng một lần.
- Các dòng được parse và insert theo batch nội bộ, tránh chi phí parse/plan của hàng nghìn statement riêng biệt.
- Bản ghi WAL được batch hóa và hiệu quả hơn, vì các thao tác ghi của
COPYlà những con đường bulk-insert được tối ưu hóa trong access method của heap. - Bạn còn có thể yêu cầu PostgreSQL bỏ qua ghi WAL cho lần nạp ban đầu vào một bảng hoặc partition rỗng trong cùng transaction mà nó được tạo, điều này an toàn vì dữ liệu không cần khả năng phục hồi sau crash ngoài phạm vi lệnh CREATE.
Các kỹ thuật tăng tốc bulk load lớn
Ngoài việc chuyển từ INSERT sang COPY, một loạt kỹ thuật bổ sung giúp tăng đáng kể throughput cho những lần nạp thực sự lớn (hàng chục triệu dòng trở lên):
1. Drop index trước khi nạp, rebuild sau khi nạp xong.
-- Trước khi nạp: drop các index không thiết yếu (giữ lại primary key nếu cần dedup)
DROP INDEX IF EXISTS idx_orders_customer_id;
DROP INDEX IF EXISTS idx_orders_created_at;
-- Bulk load
\copy orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
-- Sau khi nạp: rebuild lại index, tốt nhất với maintenance_work_mem cao hơn (xem bên dưới)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_orders_created_at ON orders (created_at);
Mỗi index được duy trì trong lúc nạp sẽ cộng thêm chi phí trên từng dòng (như đã nói ở trên); việc rebuild một lần ở cuối, theo lô, dùng thuật toán sort-and-build hiệu quả thay vì chèn từng entry vào B-tree một cách rời rạc. Đánh đổi: trong lúc nạp và trong khoảng thời gian rebuild index, bảng không có sự bảo vệ của index nào — các query dựa vào index đó sẽ chậm (sequential scan), và các ràng buộc unique dựa trên index đã drop sẽ không được thực thi cho đến khi rebuild xong. Đừng drop primary key/unique constraint nếu bạn cần bảo vệ chống duplicate ngay trong lúc nạp.
2. Tắt (hoặc trì hoãn) trigger nếu an toàn.
ALTER TABLE orders DISABLE TRIGGER USER; -- tắt mọi trigger do người dùng định nghĩa, giữ lại trigger FK nội bộ
-- ... bulk load ...
ALTER TABLE orders ENABLE TRIGGER USER;
Chỉ an toàn nếu các side effect của trigger (audit log, invalidate cache, cột tính toán) không bắt buộc phải có đối với các dòng vừa bulk load, hoặc có thể tái tạo lại hàng loạt sau đó (ví dụ, một câu UPDATE duy nhất để backfill một cột tính toán thay vì kích hoạt trigger năm triệu lần).
3. Bọc toàn bộ quá trình nạp trong một transaction duy nhất.
Một chuỗi nhiều statement gồm COPY cộng với các UPDATE tiếp theo, được bọc trong một BEGIN ... COMMIT duy nhất, tránh được chi phí commit (và fsync) sau mỗi bước, đồng thời đảm bảo nếu thất bại giữa chừng thì rollback sạch sẽ thay vì để lại một bảng nạp dở dang.
BEGIN;
TRUNCATE staging_orders;
\copy staging_orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO orders SELECT * FROM staging_orders ON CONFLICT (id) DO NOTHING;
COMMIT;
4. Tăng maintenance_work_mem để rebuild index nhanh hơn.
Việc tạo index (và VACUUM) sử dụng maintenance_work_mem, không phải work_mem. Giá trị mặc định (thường là 64MB) quá nhỏ để sort hàng trăm triệu entry index — hãy tăng nó cho session đang thực hiện bulk load và rebuild:
SET maintenance_work_mem = '2GB';
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Nhiều bộ nhớ hơn nghĩa là thao tác sort đằng sau CREATE INDEX có thể diễn ra nhiều hơn trong RAM và ít phải tràn ra đĩa qua nhiều lượt merge, có thể biến một lần build index mất 40 phút thành 5 phút trên bảng lớn.
5. Dùng bảng UNLOGGED cho dữ liệu staging.
CREATE UNLOGGED TABLE staging_orders (LIKE orders INCLUDING ALL);
\copy staging_orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
-- biến đổi/validate, rồi chuyển vào bảng thật (logged)
INSERT INTO orders SELECT * FROM staging_orders;
Bảng UNLOGGED bỏ hoàn toàn việc ghi vào WAL, khiến việc ghi vào chúng nhanh hơn đáng kể — không có chi phí flush WAL và không có lượng WAL nào cần replica phải nhận. Đánh đổi là thật: nội dung của bảng unlogged sẽ tự động bị truncate sau một crash hoặc shutdown không sạch (vì WAL là thứ mà PostgreSQL bình thường replay để làm cho bảng nhất quán sau crash, và ở đây không có WAL nào cả), và bảng unlogged hoàn toàn không được replicate đến streaming replica — một standby sẽ đơn giản thấy bảng đó rỗng. Điều này khiến UNLOGGED phù hợp cho dữ liệu staging tạm thời mà bạn sắp biến đổi và chuyển vào một bảng thật (nếu crash giữa chừng, bạn chỉ cần chạy lại việc nạp), nhưng không phù hợp cho bất cứ thứ gì cần sống sót qua crash hoặc cần hiển thị trên replica.
6. Tăng batch size / dùng nhiều luồng COPY song song cho tập dữ liệu rất lớn, bằng cách chia file nguồn thành nhiều phần và nạp mỗi phần bằng một lệnh \copy riêng trên một kết nối khác nhau, để nhiều CPU core cùng xử lý việc parse và cập nhật index đồng thời — hữu ích khi một luồng COPY đơn đã trở nên CPU-bound thay vì disk/WAL-bound.
Bảng tổng hợp: kỹ thuật bulk load
| Kỹ thuật | Mức tăng tốc điển hình | Đánh đổi |
|---|---|---|
COPY/\copy thay vì INSERT từng dòng | 10–50 lần trở lên | Yêu cầu dữ liệu ở định dạng được hỗ trợ (CSV/text/binary); ít khả năng xử lý lỗi ở cấp từng dòng hơn insert ở tầng ứng dụng |
| Drop index trước khi nạp, rebuild sau đó | 2–10 lần cho riêng bước nạp | Không có bảo vệ của index (kể cả tính unique) trong lúc nạp; phải nhớ rebuild lại |
| Tắt trigger không thiết yếu trong lúc nạp | Tùy trường hợp, có thể rất lớn nếu trigger tốn kém | Side effect của trigger (audit, invalidate cache) phải được tái tạo lại hoặc chủ động bỏ qua |
| Một transaction duy nhất cho toàn bộ quá trình nạp | Vừa phải, nhưng tránh được trạng thái nạp dở dang | Transaction chạy lâu giữ tài nguyên và trì hoãn vacuum các dead tuple ở nơi khác cho đến khi commit |
Tăng maintenance_work_mem khi build index | 2–8 lần riêng cho việc tạo index | Tốn nhiều RAM hơn trong lúc build; cần chỉnh lại hoặc giới hạn phạm vi trong session |
Bảng staging UNLOGGED | Giảm đáng kể chi phí ghi (không có WAL) | Mất dữ liệu khi crash/shutdown không sạch; không có mặt trên streaming replica |
Nhiều luồng COPY song song (chia file, nhiều kết nối) | Gần như tuyến tính theo số core, đến giới hạn disk/WAL | Phức tạp vận hành hơn; cần chia/gộp dữ liệu và xử lý lỗi đồng bộ giữa các luồng |
pg_dump/pg_restore và các công cụ ETL
COPY là công cụ đúng để nạp một file phẳng (CSV, TSV) vào một bảng đã có sẵn. Đối với việc di chuyển toàn bộ database hoặc một schema lớn giữa các server, pg_dump/pg_restore thường phù hợp hơn — chúng nắm bắt cả schema lẫn dữ liệu (bao gồm large object, sequence, và các phụ thuộc), và với định dạng custom (-Fc), pg_restore có thể song song hóa cả việc nạp dữ liệu lẫn build index qua nhiều job (--jobs), đồng thời tự động trì hoãn việc tạo index/constraint cho đến sau khi nạp dữ liệu xong. Xem Backup & Recovery để biết đầy đủ cơ chế của pg_dump/pg_restore, bao gồm lựa chọn định dạng và restore song song.
Đối với việc di chuyển dữ liệu theo pipeline, lặp lại định kỳ ở quy mô lớn — đồng bộ từ các hệ thống khác, biến đổi dữ liệu trước khi nạp vào, điều phối nạp từ nhiều nguồn theo lịch — các công cụ ETL/ELT chuyên dụng (Airflow, dbt, Fivetran, các pipeline tự viết dùng COPY bên dưới) mới là lớp phù hợp phía trên SQL thuần. Xem Data Engineer knowledge base để có góc nhìn pipeline rộng hơn; bài viết này tập trung vào những gì diễn ra bên trong một session PostgreSQL đơn lẻ.
Best Practices
- Luôn ưu tiên
INSERTnhiều dòng hoặcCOPY/\copythay vì vòng lặpINSERTtừng dòng cho bất cứ thứ gì nhiều hơn vài trăm dòng — sự khác biệt không hề nhỏ, thường chênh lệch 1–2 bậc độ lớn. - Dùng
RETURNINGthay vì mộtSELECTtiếp theo bất cứ khi nào cần biết một câu lệnh DML thực sự đã làm gì (ID được sinh ra, timestamp đã cập nhật, dòng đã xóa cần archive) — nó nguyên tử, cùng transaction, và chỉ một round trip. - Dùng
INSERT ... ON CONFLICTthay vì logic “kiểm tra rồi hành động” ở tầng ứng dụng cho bất kỳ upsert nào; nó loại bỏ hoàn toàn một nhóm race condition mà không tốn thêm công sức. - Luôn chỉ định rõ conflict target (danh sách cột hoặc tên constraint) cho
ON CONFLICT, và đảm bảo có một unique/exclusion constraint hoặc index tương ứng thực sự tồn tại — PostgreSQL sẽ không tự đoán giúp bạn. - Dùng
TRUNCATEđể xóa sạch toàn bộ bảng staging/scratch giữa các batch; dùngDELETE ... WHEREcho bất cứ thứ gì có chọn lọc hoặc phụ thuộc trigger. Nhớ rằngTRUNCATElấy khóa bảng mạnh, nên tránh dùng trên bảng “nóng” có traffic đồng thời nếu không có maintenance window. - Với các lần nạp lớn: drop index không quan trọng, bọc việc nạp trong một transaction, tăng
maintenance_work_memcho session, và rebuild index sau đó — hãy thử nghiệm trình tự này trên bản sao staging với dữ liệu có kích thước tương đương production trước, vì tổ hợp “đúng” phụ thuộc vào từng workload cụ thể. - Ưu tiên
\copyhơnCOPYtrừ khi bạn thực sự cần truy cập file phía server và có đủ quyền —\copyhoạt động nhất quán dù bạn kết nối tới localhost hay một database cloud được quản lý không có shell access. - Chỉ dùng bảng
UNLOGGEDcho dữ liệu staging thực sự có thể bỏ đi, có thể tái tạo hoặc import lại; đừng bao giờ dùng cho dữ liệu cần sống sót qua crash hoặc cần hiển thị trên read replica. - Sau bất kỳ lần bulk load hoặc bulk delete lớn nào, chạy
ANALYZE(và nếu đã xóa nhiều, cân nhắcVACUUM) để thống kê của planner phản ánh đúng khối lượng dữ liệu mới — nạp hàng loạt vào một bảng mà planner vẫn nghĩ là rỗng sẽ ngay lập tức dẫn đến kế hoạch truy vấn tệ. - Với việc di chuyển dữ liệu thực sự lớn giữa các môi trường (toàn bộ database, hoặc nạp định kỳ theo lịch), hãy dùng
pg_dump/pg_restorehoặc công cụ ETL thay vì scriptCOPYtự viết tay — xem Backup & Recovery và Data Engineer knowledge base.
Tài liệu tham khảo
- PostgreSQL Docs — INSERT
- PostgreSQL Docs — UPDATE
- PostgreSQL Docs — DELETE
- PostgreSQL Docs — TRUNCATE
- PostgreSQL Docs — COPY
- PostgreSQL Docs — Populating a Database (kỹ thuật bulk load)
- PostgreSQL Docs — psql \copy meta-command
- roadmap.sh — PostgreSQL DBA
Bài viết liên quan: Truy vấn dữ liệu & Nền tảng SQL · Chiến lược Indexing · Backup & Recovery
Part of the PostgreSQL DBA Roadmap knowledge base.
Overview
Reading data gets most of the attention in SQL tutorials, but a DBA spends just as much time thinking about how data gets written. Every INSERT, UPDATE, and DELETE has to go through PostgreSQL’s transactional machinery — MVCC snapshots, WAL (write-ahead log) records, index maintenance, trigger firing — and the cost of that machinery scales very differently depending on whether you’re touching one row or ten million.
This note covers the everyday DML statements and the clauses that make them more powerful (RETURNING, ON CONFLICT), the sharp edges of TRUNCATE vs DELETE, and — the part that matters most operationally — how to load large volumes of data efficiently with COPY and a handful of techniques that turn an overnight import into a five-minute one.
Fundamentals
INSERT, UPDATE, DELETE
The three basic DML statements are straightforward on the surface:
-- INSERT a single row
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (101, 55, 3, 'pending');
-- INSERT multiple rows in one statement (much faster than one INSERT per row)
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES
(101, 55, 3, 'pending'),
(102, 12, 1, 'pending'),
(103, 78, 5, 'pending');
-- UPDATE with a WHERE clause
UPDATE orders
SET status = 'shipped', shipped_at = now()
WHERE id = 42;
-- UPDATE joining against another table
UPDATE orders o
SET status = 'cancelled'
FROM customers c
WHERE o.customer_id = c.id
AND c.is_blocked = true
AND o.status = 'pending';
-- DELETE with a WHERE clause
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < now() - interval '1 year';
A few things worth internalizing early:
- Every
UPDATEin PostgreSQL is, under MVCC, actually a delete-plus-insert at the storage level: the old row version is marked as no-longer-visible and a new row version is written (unless HOT — Heap-Only Tuple — updates apply). This is why heavily updated tables need regularVACUUMto reclaim dead tuples. - A multi-row
INSERT(the second example above) is far cheaper than the same number of single-rowINSERTstatements, because it amortizes parsing, planning, and WAL flushing over many rows in one round trip. WHERE-lessUPDATEorDELETEstatements touch the entire table — always double check theWHEREclause, ideally by first running the equivalentSELECTto see what would be affected.
RETURNING — getting affected rows back for free
A feature many engineers coming from other databases don’t know PostgreSQL has: any INSERT, UPDATE, or DELETE can end with a RETURNING clause that hands back the affected rows, exactly as if you had run a SELECT — but without a second round trip or a separate query.
-- Get the generated id and timestamp back from an INSERT
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (101, 55, 3, 'pending')
RETURNING id, created_at;
-- id | created_at
-- -----+-------------------------------
-- 871 | 2026-07-19 09:12:44.128374+00
-- Capture which rows an UPDATE actually touched
UPDATE orders
SET status = 'shipped', shipped_at = now()
WHERE status = 'pending' AND created_at < now() - interval '2 days'
RETURNING id, customer_id, status;
-- Get back the rows you just deleted, e.g. to log them or move them elsewhere
DELETE FROM sessions
WHERE expires_at < now()
RETURNING session_id, user_id;
-- RETURNING * returns every column
INSERT INTO audit_log (event, payload) VALUES ('login', '{"ip":"10.0.0.5"}')
RETURNING *;
RETURNING is not just a convenience. Without it, the common pattern for “insert and get the new ID” is either a separate SELECT currval(...) / lastval() call, or a round trip that re-reads the row you just wrote — both slower and, in concurrent workloads, subtly racy (another session’s insert could shift what lastval() reports if you’re not careful about session-scoping). RETURNING guarantees you get back exactly the rows your own statement affected, in the same transaction, in one network round trip. It also composes with INSERT ... SELECT and CTEs, which enables patterns like “delete from one table and insert the deleted rows into an archive table” in a single statement via a data-modifying CTE:
WITH moved AS (
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < now() - interval '1 year'
RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM moved;
TRUNCATE vs DELETE
Both remove rows, but they are implemented completely differently and the differences matter operationally.
| Aspect | DELETE FROM table | TRUNCATE TABLE |
|---|---|---|
Row-level WHERE filter | Yes, can delete a subset | No, always removes all rows |
| Speed on large tables | Slow — scans and marks each row as dead (MVCC), one WAL record per row (or per page) | Fast — deallocates whole data files, a handful of WAL records regardless of row count |
| Triggers | Fires ON DELETE row-level triggers, once per row | Row-level triggers do not fire; only TRUNCATE-specific statement-level triggers do |
| Sequence / identity reset | No — sequences keep their current value | Yes, if RESTART IDENTITY is specified (TRUNCATE table RESTART IDENTITY), it resets SERIAL/IDENTITY sequences to their start value |
| Space reclaimed immediately | No — needs a subsequent VACUUM (or autovacuum) to reclaim space; row slots are just marked dead | Yes — file space is released back to the OS right away |
| Transactional / rollback-safe | Yes, fully transactional and MVCC-safe (other transactions still see old rows until commit) | Yes, transactional in PostgreSQL (unlike some other databases) — a ROLLBACK undoes it — but it takes an ACCESS EXCLUSIVE lock on the table |
| Can run inside a larger multi-statement transaction with concurrent readers | Yes, blocks less (row/tuple-level locking) | Acquires a strong table-level lock, which blocks all concurrent reads and writes on that table until commit |
| Foreign keys | Works normally, respects ON DELETE actions | Fails (or requires CASCADE) if other tables have foreign keys referencing this one, unless truncating them together: TRUNCATE a, b CASCADE; |
| Returns affected rows | Supports RETURNING | Does not support RETURNING |
-- DELETE: selective, triggers fire, slow on big tables, needs VACUUM afterwards
DELETE FROM sessions WHERE expires_at < now();
-- TRUNCATE: wipes the whole table instantly, resets the identity sequence
TRUNCATE TABLE staging_import RESTART IDENTITY;
-- Truncate several related tables at once, following FK dependencies
TRUNCATE TABLE orders, order_items CASCADE;
Rule of thumb: use TRUNCATE for wiping staging tables between ETL batches or clearing an entire table you fully control (no partial deletes needed); use DELETE whenever you need a WHERE clause, must fire row-level triggers, or are removing a small-to-moderate fraction of rows from a live table with concurrent traffic.
Key Concepts
Upserts: INSERT … ON CONFLICT
The classic “insert or update if it already exists” problem historically required an application to SELECT first, then decide whether to INSERT or UPDATE. That pattern has a race condition: between the SELECT and the following INSERT/UPDATE, another transaction can insert the same row, and you either get a duplicate-key error or silently overwrite data incorrectly.
PostgreSQL solves this atomically, at the row level, with INSERT ... ON CONFLICT:
CREATE TABLE product_inventory (
sku text PRIMARY KEY,
quantity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Do nothing if the row already exists (silently skip duplicates)
INSERT INTO product_inventory (sku, quantity)
VALUES ('WIDGET-001', 50)
ON CONFLICT (sku) DO NOTHING;
-- Update the existing row if a conflict is detected (the real "upsert")
INSERT INTO product_inventory (sku, quantity, updated_at)
VALUES ('WIDGET-001', 50, now())
ON CONFLICT (sku)
DO UPDATE SET
quantity = product_inventory.quantity + EXCLUDED.quantity,
updated_at = now();
A few things to unpack:
- Conflict target:
ON CONFLICT (sku)names the column(s) that must have a unique or exclusion constraint (or a matching unique index) for PostgreSQL to know which constraint violation to catch. You can also target a named constraint directly:ON CONFLICT ON CONSTRAINT product_inventory_pkey DO UPDATE .... Without a valid conflict target that matches an existing unique/exclusion constraint, PostgreSQL will raise an error rather than guess. EXCLUDED: inside theDO UPDATE SETclause, the pseudo-tableEXCLUDEDrefers to the row that would have been inserted had there been no conflict — i.e., the new values from yourVALUESclause. This lets you reference “the value I was trying to insert” versusproduct_inventory.quantity, which is “the value currently stored.”- Atomicity: the whole check-and-act sequence happens inside a single statement, protected by the same lock that would be taken for a plain
INSERT. Two concurrent sessions upserting the same key will serialize on that row’s lock instead of racing — one wins the insert, the other sees the conflict and applies itsDO UPDATE, with no duplicate-key error and no lost update. - You can add a
WHEREclause toDO UPDATEto make it conditional:ON CONFLICT (sku) DO UPDATE SET quantity = EXCLUDED.quantity WHERE product_inventory.updated_at < EXCLUDED.updated_at(only apply the update if the incoming row is newer — a common “last write wins by timestamp” pattern). ON CONFLICTalso composes withRETURNING, so you can find out in one round trip whether a row was inserted or updated by checking, e.g.,xmaxin older approaches, or more simply by comparing timestamps returned.
-- Bulk upsert from a VALUES list — very common pattern for syncing external data
INSERT INTO product_inventory (sku, quantity, updated_at)
VALUES
('WIDGET-001', 12, now()),
('WIDGET-002', 40, now()),
('GADGET-010', 5, now())
ON CONFLICT (sku)
DO UPDATE SET
quantity = EXCLUDED.quantity,
updated_at = EXCLUDED.updated_at;
Why row-by-row INSERT doesn’t scale for bulk loads
Loading a large dataset — say, five million rows from a CSV export — by issuing one INSERT statement per row is one of the most common performance mistakes. The overhead comes from several places stacking up per row:
- Per-statement overhead: each statement is parsed, planned, and executed independently. Even with a prepared statement, there’s still per-call network round-trip latency if the client sends them one at a time rather than pipelining/batching.
- WAL writes: every committed statement (or worse, every statement if you’re not batching transactions) generates its own WAL flush if you commit after each one, and
fsyncto disk is one of the more expensive operations a database performs. A single autocommitINSERTper row means potentially millions of fsyncs. - Index maintenance per row: every index on the table has to be updated on every single insert. With five indexes and five million rows, that’s 25 million index-entry insertions, each of which may trigger B-tree page splits, and each of which produces its own WAL records.
- Trigger overhead: any
BEFORE/AFTER INSERTtrigger fires once per row, multiplying whatever work it does by the row count. - Transaction overhead: if each
INSERTis its own implicit transaction (autocommit), you also pay the cost of a transaction commit (including WAL flush, unless synchronous_commit is relaxed) five million times instead of once.
The fix is not “insert faster” — it’s “use a bulk-oriented path that batches all of this.”
COPY for import/export
COPY is PostgreSQL’s purpose-built bulk data transfer command. It reads or writes many rows in one operation, streaming data directly between the client/server and the table, and it is dramatically faster than row-by-row INSERT for large loads.
-- COPY FROM: bulk load a CSV file into a table (server-side)
COPY orders (customer_id, product_id, quantity, status, created_at)
FROM '/var/lib/postgresql/import/orders.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');
-- COPY TO: bulk export a table (or query) to a CSV file (server-side)
COPY (SELECT * FROM orders WHERE created_at >= '2026-01-01')
TO '/var/lib/postgresql/export/orders_2026.csv'
WITH (FORMAT csv, HEADER true);
COPY (without a backslash) is executed entirely on the server: the file path is read/written by the PostgreSQL server process itself, on the server’s filesystem. That means the file must already exist on (or be writable on) the machine running postgres, and by default only superusers or roles with the pg_read_server_files/pg_write_server_files privilege can use it, since it lets you read/write arbitrary files as the postgres OS user.
\copy is the psql client-side variant — a psql meta-command, not real SQL — that reads the file from wherever psql itself is running (your laptop, a bastion host, a CI runner) and streams the data to the server over the regular client connection. This is the version most people actually use day to day, especially against a remote/managed database (e.g., RDS) where you have no server filesystem access at all.
# \copy runs from your local machine, no server filesystem access needed
psql "host=db.example.com dbname=app user=admin" -c "\copy orders (customer_id, product_id, quantity, status, created_at) FROM 'orders.csv' WITH (FORMAT csv, HEADER true)"
# Or interactively inside psql:
psql "host=db.example.com dbname=app user=admin"
app=> \copy orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
app=> \copy (SELECT * FROM orders WHERE status = 'shipped') TO 'shipped_orders.csv' WITH (FORMAT csv, HEADER true)
COPY | \copy | |
|---|---|---|
| Executes on | Server (postgres process) | Client (psql) |
| File location | Server’s filesystem | Local machine running psql |
| Privileges needed | Superuser or pg_read_server_files/pg_write_server_files | Whatever normal table privileges the connecting role has |
| Works against managed/remote DBs with no shell access | No | Yes |
| Underlying protocol | Native SQL command, part of the wire protocol’s COPY mode | Wraps the same COPY protocol, just relays bytes over the client connection |
COPY is so much faster than per-row INSERT because:
- It’s a single statement, single transaction (unless you split it up yourself) — one commit, one WAL flush at the end instead of per row.
- Rows are parsed and inserted in batches internally, avoiding the parse/plan overhead of thousands of separate statements.
- WAL records are batched and more efficient, since
COPYwrites are optimized bulk-insert paths in the heap access method. - You can further tell PostgreSQL to skip WAL logging for the initial load into an empty table or partition within the same transaction it’s created in, which is safe because the data doesn’t need crash-recoverability beyond the CREATE.
Techniques for speeding up large bulk loads
Beyond simply switching from INSERT to COPY, a handful of additional techniques squeeze out much more throughput for genuinely large loads (tens of millions of rows or more):
1. Drop indexes before loading, rebuild after.
-- Before the load: drop non-essential indexes (keep the primary key if you need it for dedup)
DROP INDEX IF EXISTS idx_orders_customer_id;
DROP INDEX IF EXISTS idx_orders_created_at;
-- Bulk load
\copy orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
-- After the load: rebuild indexes, ideally with more maintenance_work_mem (see below)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_orders_created_at ON orders (created_at);
Every index maintained during the load adds per-row overhead (as covered above); rebuilding once at the end, in bulk, uses an efficient sort-and-build algorithm rather than incremental B-tree insertions. The tradeoff: during the load and the index rebuild window, the table has no index protection — queries that rely on that index will be slow (sequential scans), and uniqueness constraints backed by the dropped index won’t be enforced until it’s rebuilt. Don’t drop the primary key / unique constraints if you need duplicate protection during the load itself.
2. Disable (or defer) triggers, if safe.
ALTER TABLE orders DISABLE TRIGGER USER; -- disables all user-defined triggers, keeps internal FK triggers
-- ... bulk load ...
ALTER TABLE orders ENABLE TRIGGER USER;
Only safe if the triggers’ side effects (audit logging, cache invalidation, computed columns) are not required for the bulk-loaded rows, or can be reproduced afterwards in bulk (e.g., a single UPDATE to backfill a computed column instead of firing a trigger five million times).
3. Wrap the whole load in a single transaction.
A multi-statement COPY plus follow-up UPDATEs wrapped in one BEGIN ... COMMIT avoids the overhead of committing (and fsyncing) after each step, and means a failure partway through rolls back cleanly instead of leaving a half-loaded table.
BEGIN;
TRUNCATE staging_orders;
\copy staging_orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO orders SELECT * FROM staging_orders ON CONFLICT (id) DO NOTHING;
COMMIT;
4. Increase maintenance_work_mem for faster index (re)builds.
Index creation (and VACUUM) uses maintenance_work_mem, not work_mem. The default (often 64MB) is far too small for sorting hundreds of millions of index entries — raise it for the session doing the bulk load and rebuild:
SET maintenance_work_mem = '2GB';
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
More memory means the sort behind CREATE INDEX can happen more in RAM and less by spilling to disk in multiple merge passes, which can turn a 40-minute index build into a 5-minute one on large tables.
5. Use UNLOGGED tables for staging data.
CREATE UNLOGGED TABLE staging_orders (LIKE orders INCLUDING ALL);
\copy staging_orders FROM 'orders.csv' WITH (FORMAT csv, HEADER true)
-- transform/validate, then move into the real (logged) table
INSERT INTO orders SELECT * FROM staging_orders;
UNLOGGED tables skip writing to the WAL entirely, which makes writes to them noticeably faster — there’s no WAL flush cost and no WAL volume for replicas to ship. The tradeoff is real: an unlogged table’s contents are truncated automatically after a crash or unclean shutdown (since WAL is what PostgreSQL normally replays to make a table consistent after a crash, and there is none here), and unlogged tables are not replicated to streaming replicas at all — a standby will simply have the table empty. This makes UNLOGGED a good fit for transient staging data you’re about to transform and move into a real table anyway (if you crash mid-load, you just re-run the load), but a bad fit for anything you need to survive a crash or be visible on a replica.
6. Increase batch size / use multiple parallel COPY streams for very large datasets, splitting the source file into chunks and loading each chunk with a separate \copy invocation against a different connection, so that multiple CPU cores handle parsing and index updates concurrently — useful once a single COPY stream becomes CPU-bound rather than disk/WAL-bound.
Summary table: bulk-load techniques
| Technique | Typical speed gain | Tradeoff |
|---|---|---|
COPY/\copy instead of row-by-row INSERT | 10–50x+ | Requires data in a supported format (CSV/text/binary); less row-level error handling than app-level inserts |
| Drop indexes before load, rebuild after | 2–10x on the load itself | No index protection (including uniqueness) during the load window; must remember to rebuild |
| Disable non-essential triggers during load | Varies, can be large if triggers are expensive | Trigger side effects (audit, cache invalidation) must be replayed or skipped intentionally |
| Single transaction for the whole load | Modest, but avoids partial-load states | Long-running transaction holds resources and delays vacuum of dead tuples elsewhere until commit |
Raise maintenance_work_mem for index builds | 2–8x on index creation specifically | Higher RAM usage during the build; must be tuned back down or scoped to the session |
UNLOGGED staging table | Meaningful reduction in write overhead (no WAL) | Data lost on crash/unclean shutdown; not present on streaming replicas |
Parallel COPY streams (split file, multiple connections) | Near-linear with core count, up to disk/WAL limits | More operational complexity; requires splitting/merging data and coordinating errors across streams |
pg_dump/pg_restore and ETL tools
COPY is the right tool for loading a flat file (CSV, TSV) into a table you already have. For moving an entire database or a large schema between servers, pg_dump/pg_restore are usually a better fit — they capture schema plus data (including large objects, sequences, and dependencies) and, with the custom (-Fc) format, pg_restore can parallelize both data loading and index building across multiple jobs (--jobs), plus defer index/constraint creation until after data load automatically. See Backup & Recovery for the full mechanics of pg_dump/pg_restore, including format choices and parallel restore.
For recurring, pipeline-scale data movement — syncing from other systems, transforming data before it lands, orchestrating multi-source loads on a schedule — dedicated ETL/ELT tooling (Airflow, dbt, Fivetran, custom pipelines using COPY under the hood) is the right layer above raw SQL. See Data Engineer knowledge base for that broader pipeline perspective; this note stays focused on what happens inside a single PostgreSQL session.
Best Practices
- Always prefer a multi-row
INSERTorCOPY/\copyover row-by-rowINSERTloops for anything beyond a few hundred rows — the difference is not marginal, it’s often 1–2 orders of magnitude. - Use
RETURNINGinstead of a follow-upSELECTwhenever you need to know what a DML statement actually did (generated IDs, updated timestamps, deleted rows to archive) — it’s atomic, in the same transaction, and one round trip. - Reach for
INSERT ... ON CONFLICTinstead of “check then act” application logic for any upsert; it removes an entire class of race conditions for free. - Always name an explicit conflict target (column list or constraint name) for
ON CONFLICT, and make sure a matching unique/exclusion constraint or index actually exists — PostgreSQL won’t guess one for you. - Use
TRUNCATEfor wiping entire staging/scratch tables between batches; useDELETE ... WHEREfor anything selective or trigger-dependent. RememberTRUNCATEtakes a strong table lock, so avoid it on hot tables with concurrent traffic without a maintenance window. - For large loads: drop non-critical indexes, wrap the load in a transaction, bump
maintenance_work_memfor the session, and rebuild indexes afterward — test the sequence on a staging copy of production-sized data first, since the “right” combination is workload-dependent. - Prefer
\copyoverCOPYunless you specifically need server-side file access and have the privileges for it —\copyworks uniformly whether you’re against localhost or a managed cloud database with no shell access. - Use
UNLOGGEDtables only for genuinely disposable staging data you can regenerate or re-import; never for data that must survive a crash or be visible on a read replica. - After any large bulk load or bulk delete, run
ANALYZE(and, if you did a lot of deletes, considerVACUUM) so the planner’s statistics reflect the new data volume — a bulk load into a table the planner still thinks is empty will get bad query plans immediately afterward. - For truly large data movement between environments (whole databases, or scheduled recurring loads), reach for
pg_dump/pg_restoreor an ETL tool rather than hand-rolledCOPYscripts — see Backup & Recovery and the Data Engineer knowledge base.
References
- PostgreSQL Docs — INSERT
- PostgreSQL Docs — UPDATE
- PostgreSQL Docs — DELETE
- PostgreSQL Docs — TRUNCATE
- PostgreSQL Docs — COPY
- PostgreSQL Docs — Populating a Database (bulk load techniques)
- PostgreSQL Docs — psql \copy meta-command
- roadmap.sh — PostgreSQL DBA
Related notes: Querying Data & SQL Fundamentals · Indexing Strategies · Backup & Recovery