← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202625 phút đọc20 min read

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 TRUNCATEDELETE, 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:

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ạnhDELETE FROM tableTRUNCATE TABLE
Lọc dòng theo WHERECó, có thể xóa một tập conKhông, luôn xóa toàn bộ dòng
Tốc độ trên bảng lớnChậ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
TriggerKích hoạt trigger ON DELETE ở cấp dòng, một lần cho mỗi dòngTrigger 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/identityKhông — sequence giữ nguyên giá trị hiện tạiCó, 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ứcKhông — cần VACUUM (hoặc autovacuum) sau đó để dọn không gian; các slot dòng chỉ được đánh dấu deadCó — không gian file được trả lại cho OS ngay lập tức
An toàn với transaction/rollbackCó, 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ờiCó, í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 keyHoạt động bình thường, tuân theo hành động ON DELETEThấ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ưởngHỗ trợ RETURNINGKhô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 SELECTINSERT/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õ:

-- 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:

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 ở đâuServer (tiến trình postgres)Client (psql)
Vị trí fileHệ thống file của serverMáy local đang chạy psql
Quyền cần cóSuperuser hoặc pg_read_server_files/pg_write_server_filesBấ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 accessKhông
Giao thức bên dướiCâu lệnh SQL native, một phần của chế độ COPY trong wire protocolBọ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ì:

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ậtMức tăng tốc điển hìnhĐánh đổi
COPY/\copy thay vì INSERT từng dòng10–50 lần trở lênYê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ạpKhô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ạpTùy trường hợp, có thể rất lớn nếu trigger tốn kémSide 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ạpVừa phải, nhưng tránh được trạng thái nạp dở dangTransaction 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 index2–8 lần riêng cho việc tạo indexTố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 UNLOGGEDGiả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/WALPhứ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

Tài liệu tham khảo

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:

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.

AspectDELETE FROM tableTRUNCATE TABLE
Row-level WHERE filterYes, can delete a subsetNo, always removes all rows
Speed on large tablesSlow — 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
TriggersFires ON DELETE row-level triggers, once per rowRow-level triggers do not fire; only TRUNCATE-specific statement-level triggers do
Sequence / identity resetNo — sequences keep their current valueYes, if RESTART IDENTITY is specified (TRUNCATE table RESTART IDENTITY), it resets SERIAL/IDENTITY sequences to their start value
Space reclaimed immediatelyNo — needs a subsequent VACUUM (or autovacuum) to reclaim space; row slots are just marked deadYes — file space is released back to the OS right away
Transactional / rollback-safeYes, 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 readersYes, 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 keysWorks normally, respects ON DELETE actionsFails (or requires CASCADE) if other tables have foreign keys referencing this one, unless truncating them together: TRUNCATE a, b CASCADE;
Returns affected rowsSupports RETURNINGDoes 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:

-- 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:

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 onServer (postgres process)Client (psql)
File locationServer’s filesystemLocal machine running psql
Privileges neededSuperuser or pg_read_server_files/pg_write_server_filesWhatever normal table privileges the connecting role has
Works against managed/remote DBs with no shell accessNoYes
Underlying protocolNative SQL command, part of the wire protocol’s COPY modeWraps the same COPY protocol, just relays bytes over the client connection

COPY is so much faster than per-row INSERT because:

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

TechniqueTypical speed gainTradeoff
COPY/\copy instead of row-by-row INSERT10–50x+Requires data in a supported format (CSV/text/binary); less row-level error handling than app-level inserts
Drop indexes before load, rebuild after2–10x on the load itselfNo index protection (including uniqueness) during the load window; must remember to rebuild
Disable non-essential triggers during loadVaries, can be large if triggers are expensiveTrigger side effects (audit, cache invalidation) must be replayed or skipped intentionally
Single transaction for the whole loadModest, but avoids partial-load statesLong-running transaction holds resources and delays vacuum of dead tuples elsewhere until commit
Raise maintenance_work_mem for index builds2–8x on index creation specificallyHigher RAM usage during the build; must be tuned back down or scoped to the session
UNLOGGED staging tableMeaningful 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 limitsMore 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

References

Related notes: Querying Data & SQL Fundamentals · Indexing Strategies · Backup & Recovery