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

Partitioning & ShardingPartitioning & Sharding

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Một bảng ban đầu nhỏ và hợp lý, sau vài năm và vài trăm triệu dòng, có thể trở thành thứ khiến mọi tác vụ vận hành trở nên đau đầu: sequential scan mất hàng phút thay vì mili-giây, CREATE INDEX khóa bảng trong một khoảng thời gian khó chịu, VACUUM không theo kịp lượng dead tuple sinh ra, và một lần backup bằng pg_dump kéo dài đủ lâu để đe dọa cửa sổ bảo trì. Không phải vì PostgreSQL kém trong việc lưu trữ bảng lớn — mà vì một file vật lý duy nhất (hoặc tập hợp các file segment 1 GB) buộc mọi thao tác, từ planning đến vacuuming, phải xử lý cả bảng như một khối không thể chia nhỏ.

Partitioning chính là câu trả lời của PostgreSQL: chia một bảng logic lớn thành nhiều bảng vật lý nhỏ hơn (“partition”), trong khi mọi client, ứng dụng, và câu query vẫn tương tác như thể đang làm việc với một bảng duy nhất. Làm đúng cách, kỹ thuật này biến việc scan toàn bộ 500 triệu dòng thành scan đúng 2 triệu dòng thực sự cần thiết, biến một lệnh bulk delete kéo dài hàng giờ thành một DROP TABLE gần như tức thì, và biến việc bảo trì index cùng VACUUM thành công việc có thể thực hiện từng partition một thay vì toàn bộ cùng lúc.

Bài viết này bao quát cơ chế declarative partitioning có sẵn của PostgreSQL (range, list, và hash), cách partition pruning khiến tất cả những điều trên trở nên đáng giá — và cách nó âm thầm không xảy ra khi query không được viết đúng cách — các khía cạnh vận hành khi bảo trì partition theo thời gian, và một chủ đề liên quan nhưng khác biệt: sharding — phân tán dữ liệu trên nhiều server thay vì nhiều bảng trên một server.

Kiến thức nền tảng

Partitioning thực sự là gì

Declarative partitioning, được giới thiệu từ PostgreSQL 10 và hoàn thiện dần qua các bản 11–17, cho phép bạn định nghĩa một parent table không chứa dữ liệu thực — nó chỉ khai báo chiến lược và partition key — và một hoặc nhiều child partition là các bảng thông thường thực sự chứa dữ liệu. Từ góc nhìn của ứng dụng chỉ có một bảng duy nhất: bạn INSERT, SELECT, UPDATE, DELETE trên tên của parent table, và PostgreSQL tự động điều hướng từng dòng đến đúng partition dựa trên partition key.

-- Parent table: chỉ có schema, chưa chọn chiến lược partition (sẽ chọn ở phần dưới)
CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   int         NOT NULL,
    region      text        NOT NULL,
    event_type  text        NOT NULL,
    payload     jsonb,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, created_at)          -- partition key phải nằm trong mọi unique/PK constraint
) PARTITION BY RANGE (created_at);

Chú ý phần primary key: PRIMARY KEY (id, created_at) thay vì chỉ PRIMARY KEY (id). Đây là hệ quả trực tiếp của một quy tắc sẽ được nói kỹ hơn bên dưới — mọi unique constraint (kể cả primary key) trên một partitioned table đều phải bao gồm partition key như một trong các cột, bởi vì tính duy nhất trong PostgreSQL được đảm bảo theo từng partition, chứ không phải trên toàn bộ partitioned table.

Đây là cách làm khác hẳn kỹ thuật “partitioning” kiểu cũ mà một số DBA vẫn còn nhớ từ PostgreSQL 9.x trở về trước: dùng table inheritance kết hợp CHECK constraint và trigger để điều hướng dữ liệu. Declarative partitioning đã thay thế cách làm đó cho hầu như mọi công việc mới — nó nhanh hơn, planner hiểu nó một cách tự nhiên, và việc điều hướng là tự động thay vì phải qua trigger. Vẫn đáng biết lịch sử này tồn tại, chủ yếu để nhận ra nó nếu bạn thừa hưởng một database được xây dựng từ trước PostgreSQL 10.

Ba chiến lược partitioning

PostgreSQL hỗ trợ ba cách để quyết định một dòng dữ liệu thuộc về partition nào: RANGE, LIST, và HASH. Cả ba chia sẻ cùng cơ chế parent/child — chỉ khác nhau ở quy tắc điều hướng một dòng.

Range partitioning

Range partitioning gán dòng dữ liệu vào partition dựa trên việc giá trị partition key rơi vào khoảng liên tục nào. Đây là kiểu phổ biến nhất trên một cột timestamp hoặc date, vì dữ liệu time-series — log, event, metric, order, audit trail — có một partition key tăng dần tự nhiên và một chính sách retention rõ ràng.

CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   int         NOT NULL,
    region      text        NOT NULL,
    event_type  text        NOT NULL,
    payload     jsonb,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Mỗi tháng một partition
CREATE TABLE events_2026_05 PARTITION OF events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE events_2026_06 PARTITION OF events
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE TABLE events_2026_07 PARTITION OF events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- Partition "bắt tất cả" cho các dòng nằm ngoài mọi range đã tạo (không bắt buộc nhưng nên có)
CREATE TABLE events_default PARTITION OF events DEFAULT;

FOR VALUES FROM (...) TO (...) là một khoảng nửa-mở (half-open interval): FROM bao gồm giá trị biên, TO không bao gồm giá trị biên. Đây là lý do các giá trị biên khớp gọn gàng giữa các tháng liên tiếp — 2026-06-01 vừa là biên trên (loại trừ) của tháng 5, vừa là biên dưới (bao gồm) của tháng 6, không có khoảng hở và không chồng lấn.

Tính năng “sát thủ” của range partitioning theo thời gian chính là retention. Xóa một năm log cũ bằng DELETE FROM events WHERE created_at < now() - interval '1 year' trên một bảng 500 triệu dòng không được partition nghĩa là phải scan các dòng khớp điều kiện, sinh WAL cho từng dòng bị xóa, để lại dead tuple cho VACUUM dọn dẹp, và giữ lock suốt quá trình đó. Cách làm tương đương trên một bảng đã partition là:

-- Detach và drop cả một tháng chỉ trong vài mili-giây, thay vì DELETE chậm từng dòng
ALTER TABLE events DETACH PARTITION events_2025_05;
DROP TABLE events_2025_05;

Đây là một thao tác metadata — không cần scan từng dòng, không gây bloat WAL từ hàng triệu tuple bị xóa, gần như tức thì bất kể partition chứa bao nhiêu dòng. Với bất kỳ bảng nào có cửa sổ retention xoay vòng (giữ 90 ngày, giữ 2 năm, giữ 13 tháng), riêng lợi ích này đã đủ để biện minh cho range partitioning.

List partitioning

List partitioning gán dòng dữ liệu vào partition dựa trên việc thuộc về một tập giá trị rời rạc, tường minh, thay vì một khoảng liên tục. Nó phù hợp tự nhiên ở bất cứ đâu dữ liệu vốn đã rơi vào một số ít danh mục có tên gọi: region, tenant, quốc gia, status, hoặc bất kỳ cột nào có từ vựng giới hạn, đã biết trước.

CREATE TABLE customer_data (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    customer_id bigint      NOT NULL,
    region      text        NOT NULL,
    full_name   text,
    email       text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE customer_data_eu PARTITION OF customer_data
    FOR VALUES IN ('DE', 'FR', 'NL', 'IT', 'ES');

CREATE TABLE customer_data_us PARTITION OF customer_data
    FOR VALUES IN ('US', 'CA');

CREATE TABLE customer_data_apac PARTITION OF customer_data
    FOR VALUES IN ('SG', 'JP', 'AU', 'IN');

CREATE TABLE customer_data_other PARTITION OF customer_data DEFAULT;

Ngoài khía cạnh hiệu năng, list partitioning theo region còn là công cụ phổ biến cho yêu cầu về data residency và tuân thủ (compliance) (GDPR và các quy định tương tự): nếu dữ liệu khách hàng EU bắt buộc phải nằm vật lý trong khu vực EU, customer_data_eu có thể nằm trên một tablespace lưu trữ tại vùng EU trong khi customer_data_us nằm ở nơi khác, tất cả trong khi ứng dụng vẫn chỉ query một bảng logic customer_data duy nhất.

Hash partitioning

Hash partitioning là lựa chọn khi không có ranh giới range hay list tự nhiên nào, nhưng bạn vẫn muốn có lợi ích vận hành của việc chia nhỏ một bảng khổng lồ — thường là để phân tán đều tải ghi và dung lượng lưu trữ, hoặc để cho phép các thao tác bảo trì (vacuum, reindex) chạy song song, độc lập giữa các partition. PostgreSQL băm (hash) giá trị partition key và gán dòng vào một trong N partition dựa trên (giá trị hash) MOD N, sử dụng các cặp REMAINDER/MODULUS phải phủ kín toàn bộ không gian giá trị.

CREATE TABLE sessions (
    id          uuid        NOT NULL DEFAULT gen_random_uuid(),
    user_id     bigint      NOT NULL,
    started_at  timestamptz NOT NULL DEFAULT now(),
    data        jsonb,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- 4 partition phủ kín toàn bộ hash space: modulus 4, remainder 0..3
CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Hash partitioning không có partition DEFAULT — mọi giá trị hash có thể có đều được đảm bảo rơi vào đúng một trong các nhóm MODULUS/REMAINDER, nên không tồn tại trường hợp “còn sót lại” để bắt. Đánh đổi so với range hay list partitioning là các hash partition thường không mang ý nghĩa gì cho việc pruning trong hầu hết query thực tế — bạn hiếm khi viết WHERE user_id = 12345 và nghĩ theo hướng “partition nào”, nên hash partitioning giúp về mặt vận hành (đơn vị nhỏ hơn để vacuum/reindex, phân tán I/O qua nhiều tablespace) nhiều hơn là giúp giảm latency của query, trừ khi query thực sự lọc bằng một điều kiện equality trên hash key.

Khái niệm chính

Partition pruning

Partition pruning là cơ chế khiến tất cả những điều ở trên trở nên đáng giá: khi query planner có thể chứng minh, từ mệnh đề WHERE và ranh giới partition, rằng một partition không thể chứa dòng nào khớp điều kiện, nó sẽ loại bỏ hoàn toàn partition đó khỏi plan — không scan, không I/O, thậm chí không được xem xét lúc thực thi (với runtime pruning, điều này thậm chí có thể xảy ra với các query tham số hóa và điều kiện JOIN được phát hiện trong lúc thực thi, chứ không chỉ với hằng số đã biết tại thời điểm lập plan).

-- Được prune: planner biết chỉ events_2026_06 có thể khớp, và bỏ qua mọi partition khác
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-15';

-- KHÔNG được prune: không có filter trên partition key (created_at), phải scan mọi partition
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE event_type = 'login_failed';

Hệ quả quan trọng, dễ bị bỏ sót: pruning chỉ có tác dụng nếu query thực sự filter trên partition key. Một query chỉ filter trên event_type sẽ không được lợi gì từ việc partition theo range created_at — planner buộc phải scan từng partition một, vì bất kỳ partition nào cũng có thể chứa một dòng login_failed. Trong tình huống đó, partitioning chỉ thêm overhead của việc lập plan phức tạp hơn và nhiều executor node theo từng partition mà không mang lại lợi ích chính của nó; một index tốt trên event_type sẽ giúp ích nhiều hơn, hoặc cần xem xét partition theo một key khác, hoặc (thường là câu trả lời đúng) áp dụng cách tiếp cận kết hợp với created_at xuất hiện trong mọi query cùng các điều kiện lọc khác.

Đây chính là lý do nên đọc bài viết này cùng với ./10-query-planning-and-performance-tuning.md — pruning là một hành vi của planner, và cách đáng tin cậy duy nhất để biết nó đã xảy ra là xem plan:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02'
  AND tenant_id = 42;

Trong output của EXPLAIN, hãy tìm node Append hoặc Merge Append có danh sách con chỉ gồm các partition bạn mong đợi — nếu một query “chạy trên bảng đã partition” mà vẫn chậm, và plan cho thấy mọi partition đều bị scan bên dưới Append, đó chính là dấu hiệu tương đương với việc thiếu index: schema thì đúng, nhưng query lại không được viết để tận dụng nó. Đừng bao giờ cho rằng pruning đã xảy ra chỉ vì bảng đã được partition — hãy xác minh mỗi lần có một pattern query mới hoặc thay đổi.

Bảo trì partition

Các bảng range-partitioned theo cửa sổ thời gian xoay vòng cần được tạo partition mới trước khi dữ liệu của khoảng thời gian đó bắt đầu đến, và cần rút bỏ (retire) các partition cũ khi chúng vượt quá cửa sổ retention. Nếu bỏ qua việc này, các dòng mới hoặc sẽ bị từ chối (nếu không có partition khớp và không có DEFAULT) hoặc âm thầm chất đống trong một partition DEFAULT vốn không được đánh index cho mục đích đó, làm mất đi toàn bộ ý nghĩa của việc partition.

-- Tạo trước partition của tháng tiếp theo, trước khi dữ liệu tháng 7 bắt đầu đến
CREATE TABLE events_2026_08 PARTITION OF events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Hai công cụ giúp việc này an toàn dưới tải: ATTACH/DETACH PARTITION cho phép bạn xây dựng hoặc kiểm chứng dữ liệu của một partition ở ngoài (out-of-band) và chỉ giữ lock trong thời gian ngắn để attach hoặc detach nó, còn CREATE TABLE ... (LIKE events INCLUDING ALL) cho phép bạn tạo trước một bảng có cùng cấu trúc trước khi biến nó thành partition.

-- Xây dựng một bảng có cấu trúc giống partition ở "bên ngoài", nạp/kiểm chứng dữ liệu, rồi attach
CREATE TABLE events_2026_08 (LIKE events INCLUDING ALL);
-- ... bulk load hoặc migrate dữ liệu vào events_2026_08 tại đây ...
ALTER TABLE events ATTACH PARTITION events_2026_08
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- Detach một partition để archive riêng, không xóa ngay
ALTER TABLE events DETACH PARTITION events_2024_01;

Làm việc này bằng tay mỗi tháng không phải là thói quen vận hành có thể mở rộng — đây là kiểu tác vụ dễ bị quên cho đến khi ca trực phát hiện ra không có partition nào cho ngày hôm nay. Trong thực tế, việc này gần như luôn được tự động hóa, hoặc bằng một job theo lịch (cron, CronJob của Kubernetes, hay bộ điều phối bất kỳ) chạy một script nhỏ để tạo trước N partition tiếp theo và drop/detach các partition đã quá hạn retention, hoặc bằng extension pg_partman, vốn quản lý chính xác vòng đời này — tạo trước theo một cửa sổ “premake” có thể cấu hình, drop/detach dựa trên retention, thậm chí migrate ngầm các bảng chưa partition có sẵn sang cấu trúc đã partition.

Constraint và index trên bảng đã partition

Index trên một bảng đã partition có thể được khai báo một lần duy nhất trên parent, và PostgreSQL sẽ tự động lan truyền một index tương đương lên mọi partition hiện có lẫn tương lai:

-- Khai báo một lần trên parent; PostgreSQL tạo index tương ứng trên mọi partition
CREATE INDEX idx_events_tenant_type ON events (tenant_id, event_type);

-- Partition mới attach sau này cũng tự động có index tương đương
CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
-- events_2026_09 đã có idx_events_tenant_type_<suffix> ngay tại thời điểm này

Tuy nhiên bên dưới, không hề có một index vật lý duy nhất trải rộng qua mọi partition — mỗi partition có index riêng, độc lập, được xây dựng trên dữ liệu của chính nó (có thể thấy qua \d events_2026_09 như một đối tượng index riêng biệt), và PostgreSQL chỉ giữ cho định nghĩa index trên parent và tập các index theo từng partition luôn nhất quán khi partition được thêm/bớt. Điều này quan trọng đối với bảo trì: REINDEX hay VACUUM vẫn diễn ra theo từng partition, và đây chính là phần lớn lý do partitioning có tác dụng — các index nhỏ hơn, độc lập rebuild và vacuum nhanh hơn một index khổng lồ duy nhất.

Unique constraint (và primary key, về bản chất chỉ là unique constraint cộng thêm NOT NULL) có một yêu cầu bắt buộc đáng ghi nhớ ngay từ đầu: partition key phải là một phần của mọi unique constraint trên bảng đã partition. Đây không phải khuyến nghị về phong cách viết code — PostgreSQL đảm bảo tính duy nhất trong phạm vi từng partition một cách độc lập, nên nó không có cách nào đảm bảo tính duy nhất toàn cục của một key không chứa partition key, và sẽ từ chối tạo constraint nếu vi phạm:

-- Câu này SẼ LỖI: unique constraint không bao gồm partition key (created_at)
ALTER TABLE events ADD CONSTRAINT events_id_unique UNIQUE (id);
-- ERROR: unique constraint on partitioned table must include all partitioning columns

-- Câu này THÀNH CÔNG: đã bao gồm partition key
ALTER TABLE events ADD CONSTRAINT events_id_created_unique UNIQUE (id, created_at);

Trong thực tế, điều này có nghĩa là một id tự tăng đơn thuần không còn là bảo đảm duy nhất duy nhất bạn có thể dựa vào trên một bảng đã partition — bạn hoặc chấp nhận một khóa composite bao gồm cột partition (như trong các ví dụ xuyên suốt bài viết này), hoặc đảm bảo tính duy nhất toàn cục của id bằng cách khác (một bộ sinh giá trị đã duy nhất sẵn theo cấu trúc, như bigint GENERATED ALWAYS AS IDENTITY được sinh từ một nguồn tập trung, hoặc UUID, chấp nhận rằng bản thân PostgreSQL sẽ không kiểm tra lại điều đó xuyên suốt các partition).

Sharding so với partitioning

Partitioning, như đã trình bày ở trên, chia một bảng logic thành nhiều bảng vật lý nhưng tất cả vẫn nằm trên một instance PostgreSQL duy nhất. Nó giải quyết vấn đề về kích thước scan, kích thước index, thời gian vacuum, và độ chi tiết của bảo trì, nhưng mọi partition vẫn cạnh tranh cùng CPU, RAM, và I/O đĩa của chính máy đó — luôn có một giới hạn trên cho lượng tải mà phần cứng của một instance duy nhất có thể hấp thụ, dù bảng có được partition tốt đến đâu.

Sharding giải quyết một vấn đề khác: phân tán dữ liệu qua nhiều instance PostgreSQL riêng biệt, để tổng năng lực (CPU, RAM, đĩa, số connection) mở rộng bằng cách thêm máy chứ không phải bằng cách làm cho một máy lớn hơn. Trong khi partitioning là một tính năng có sẵn, hạng nhất của PostgreSQL, thì PostgreSQL thuần không có sharding có sẵn — không có cú pháp CREATE TABLE ... SHARD BY nào phân tán dữ liệu qua nhiều server ngay trong hộp. Để đạt được khả năng mở rộng theo chiều ngang (horizontal scale-out) với PostgreSQL, bạn cần chọn (hoặc kết hợp) một trong vài hướng tiếp cận sau:

Hai khái niệm này bổ sung cho nhau chứ không cạnh tranh, và các hệ thống production ở quy mô lớn thường dùng cả hai cùng lúc: mỗi shard (dù được xây dựng thủ công ở tầng ứng dụng hay là một worker node của Citus) tự nó là một instance PostgreSQL chứa một phần dữ liệu — chẳng hạn, một khoảng tenant_id — và bên trong instance đó, các bảng nó chứa lại tiếp tục được chia nhỏ bằng declarative partitioning thông thường theo, ví dụ, created_at. Partitioning tổ chức dữ liệu bên trong một instance; sharding phân tán dữ liệu qua nhiều instance; một hệ thống quy mô lớn được thiết kế tốt thường làm cả hai, mỗi thứ giải quyết vấn đề ở tầng phù hợp với nó. Nếu bạn muốn đọc tiếp sang khía cạnh rộng hơn của hệ thống phân tán (consistent hashing, replication, các distributed query engine như worker của Citus hoặc các hệ thống hoàn toàn nằm ngoài PostgreSQL như Spark hay một data warehouse), xem thêm ../data-engineer/en/10-big-data-and-distributed-computing.md.

Best Practices

Partitioning không phải là mặc định bạn nên áp dụng cho mọi bảng vượt qua một ngưỡng số dòng nào đó — đây là một sự đánh đổi có chủ đích: thêm độ phức tạp vận hành và lập plan để đổi lấy việc giải quyết một điểm đau cụ thể, đã thực sự được quan sát thấy. Một bảng đã partition có nhiều thành phần chuyển động hơn một bảng thường: mỗi partition thêm vào sẽ cộng thêm một chút overhead lập plan (nhân với hàng nghìn query đồng thời, con số này trở nên đáng kể), các job bảo trì (tạo/rút bỏ partition) trở thành một thứ khác có thể âm thầm thất bại, và các ràng buộc như yêu cầu bắt buộc partition key trong unique index sẽ lan sang các giả định ở tầng ứng dụng về primary key. Tín hiệu đúng để bắt đầu partition là bằng chứng, không phải dự đoán trước: các lần VACUUM bị tụt lại phía sau hoặc mất hàng giờ, việc build index hay REINDEX cần đến cửa sổ bảo trì riêng, backup vượt quá cửa sổ cho phép, hoặc query plan bị chi phối bởi việc scan nhiều dữ liệu hơn hẳn mức một query thực sự cần về mặt logic. Partition một bảng 2 triệu dòng “vì có thể sau này nó sẽ lớn” chỉ thêm độ phức tạp thực sự mà không mang lại lợi ích hiện tại nào, đây là ví dụ điển hình của tối ưu hóa sớm (premature optimization); trong khi partition một bảng events 500 triệu dòng, vẫn đang tăng, mất 6 giờ để VACUUM gần như là bắt buộc.

Khi đã quyết định partition, hãy chọn chiến lược khớp với pattern truy cập thực tế thay vì chọn thứ thuận tiện về mặt cấu trúc: range partitioning theo thời gian chỉ đáng giá nếu các query thường xuyên filter theo một khoảng thời gian (và càng đáng giá hơn nếu có chính sách retention biến thành DROP/DETACH thay vì DELETE); list partitioning theo một cột phân loại chỉ giúp ích nếu các query thường xuyên filter theo danh mục đó, hoặc nếu có một yêu cầu bắt buộc (như data residency) buộc phải chia theo cách đó bất kể pattern query; hash partitioning là công cụ đúng đắn cụ thể khi không có range hay danh mục tự nhiên nào để tận dụng và mục tiêu thực sự là phân tán đều tải lưu trữ và bảo trì, chứ không phải tăng tốc một query cụ thể nào thông qua pruning. Dù chọn chiến lược nào, hãy xem partition pruning là thứ cần xác minh, không phải mặc định là đúng — chạy EXPLAIN (ANALYZE, BUFFERS) với các query thực tế, đúng hình dạng production sau khi partition, và xác nhận các node con của Append đúng là các partition bạn mong đợi, đồng thời xem lại bất kỳ query nào có điều kiện filter không chạm đến partition key, vì query đó đang phải trả overhead của partitioning mà không nhận được lợi ích của nó.

Cuối cùng, hãy dự trù vòng đời bảo trì ngay từ đầu thay vì để sau: quyết định cửa sổ premake (tạo trước partition mới bao xa) và cửa sổ retention (partition cũ tồn tại bao lâu) ngay từ đầu, tự động hóa cả hai bằng một job theo lịch hoặc pg_partman thay vì trông chờ con người nhớ chạy script hàng tháng, và ghi nhớ rằng thiếu partition tương lai là một kiểu lỗi cứng (insert hoặc báo lỗi hoặc rơi vào một partition DEFAULT không được đánh index đúng cách) trong khi thiếu việc rút bỏ partition cũ là một kiểu lỗi âm thầm, từ từ (các partition cũ lặng lẽ tích tụ và bào mòn chính lợi ích mà partitioning được kỳ vọng mang lại). Nếu và khi năng lực của một instance duy nhất mới thực sự là điểm nghẽn, chứ không phải đặc tính riêng của từng bảng, đó là tín hiệu để nhìn sang sharding — ở tầng ứng dụng hoặc qua Citus — thay vì cố giải quyết vấn đề năng lực đa-server bằng cách partition mạnh hơn trên một máy duy nhất.

Tài liệu tham khảo

Xem thêm: ./08-indexing-strategies.md, ./10-query-planning-and-performance-tuning.md, ../data-engineer/en/10-big-data-and-distributed-computing.md.

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

A table that started small and reasonable can, a few years and a few hundred million rows later, become the thing that makes every operational task painful: sequential scans take minutes instead of milliseconds, CREATE INDEX locks the table for an uncomfortable window, VACUUM struggles to keep up with dead tuples, and a pg_dump backup takes long enough to threaten the maintenance window. None of this is because PostgreSQL is bad at storing large tables — it’s because a single physical file (or set of 1 GB segment files) forces every operation, from planning to vacuuming, to reason about the table as one indivisible unit.

Partitioning is PostgreSQL’s answer: split one large logical table into many smaller physical tables (“partitions”), while every client, application, and query continues to interact with what looks like a single table. Done well, this turns a full scan of 500 million rows into a scan of the 2 million rows that actually matter, turns a multi-hour bulk delete into an instant DROP TABLE, and turns index maintenance and VACUUM into work that can happen partition-by-partition instead of all at once.

This note covers PostgreSQL’s built-in declarative partitioning (range, list, and hash), how partition pruning makes it all worthwhile — and how it silently doesn’t when queries aren’t shaped correctly — the operational mechanics of maintaining partitions over time, and the related-but-distinct topic of sharding: spreading data across multiple servers rather than multiple tables on one server.

Fundamentals

What partitioning actually is

Declarative partitioning, introduced in PostgreSQL 10 and matured through 11–17, lets you define a parent table that carries no data of its own — it only declares the partitioning strategy and key — and one or more child partitions that are ordinary tables holding the actual rows. From the application’s point of view there is one table: you INSERT, SELECT, UPDATE, and DELETE against the parent’s name, and PostgreSQL routes each row to the correct partition automatically based on the partition key.

-- The parent table: schema only, no partitioning strategy chosen yet = pick one below
CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   int         NOT NULL,
    region      text        NOT NULL,
    event_type  text        NOT NULL,
    payload     jsonb,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, created_at)          -- partition key must be part of any unique/PK constraint
) PARTITION BY RANGE (created_at);

Note the primary key: PRIMARY KEY (id, created_at) rather than just PRIMARY KEY (id). This is a direct consequence of a rule covered in more detail below — every unique constraint (including a primary key) on a partitioned table must include the partition key as one of its columns, because uniqueness in PostgreSQL is enforced per-partition, not globally across the whole partitioned table.

This is distinct from the old “partitioning” technique some DBAs remember from PostgreSQL 9.x and earlier: table inheritance plus CHECK constraints plus trigger-based routing. Declarative partitioning replaced that pattern for essentially all new work — it’s faster, the planner understands it natively, and routing is automatic instead of trigger-based. It’s worth knowing that history exists, mostly so you recognize it if you inherit a database built before PostgreSQL 10.

The three partitioning strategies

PostgreSQL supports three ways to decide which partition a given row belongs in: RANGE, LIST, and HASH. All three share the same parent/child mechanics — only the rule for routing a row differs.

Range partitioning

Range partitioning assigns rows to partitions based on where the partition key value falls within a contiguous range. It is overwhelmingly most common on a timestamp or date column, because time-series data — logs, events, metrics, orders, audit trails — has an obvious, ever-advancing partition key and a natural retention policy.

CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   int         NOT NULL,
    region      text        NOT NULL,
    event_type  text        NOT NULL,
    payload     jsonb,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- One partition per month
CREATE TABLE events_2026_05 PARTITION OF events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE events_2026_06 PARTITION OF events
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE TABLE events_2026_07 PARTITION OF events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- A catch-all for rows outside any explicitly created range (optional but recommended)
CREATE TABLE events_default PARTITION OF events DEFAULT;

FOR VALUES FROM (...) TO (...) is a half-open interval: FROM is inclusive, TO is exclusive. This is why the boundary values line up cleanly across consecutive months — 2026-06-01 is the exclusive upper bound of May and the inclusive lower bound of June, with no gap and no overlap.

The killer feature of range partitioning on time is retention. Deleting a year of old log data with DELETE FROM events WHERE created_at < now() - interval '1 year' on a 500-million-row unpartitioned table means scanning matching rows, generating WAL for every deleted row, leaving behind dead tuples for VACUUM to clean up, and holding locks the whole time. The equivalent on a partitioned table is:

-- Detach and drop a whole month in milliseconds, instead of a slow row-by-row DELETE
ALTER TABLE events DETACH PARTITION events_2025_05;
DROP TABLE events_2025_05;

This is a metadata operation — no row-by-row scanning, no WAL bloat from millions of deleted tuples, near-instant regardless of how many rows the partition holds. For any table with a rolling retention window (keep 90 days, keep 2 years, keep 13 months), this alone tends to justify range partitioning.

List partitioning

List partitioning assigns rows to partitions based on membership in an explicit, discrete set of values, rather than a range. It fits naturally wherever the data already falls into a small number of named categories: region, tenant, country, status, or any other column with a bounded, known vocabulary.

CREATE TABLE customer_data (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    customer_id bigint      NOT NULL,
    region      text        NOT NULL,
    full_name   text,
    email       text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE customer_data_eu PARTITION OF customer_data
    FOR VALUES IN ('DE', 'FR', 'NL', 'IT', 'ES');

CREATE TABLE customer_data_us PARTITION OF customer_data
    FOR VALUES IN ('US', 'CA');

CREATE TABLE customer_data_apac PARTITION OF customer_data
    FOR VALUES IN ('SG', 'JP', 'AU', 'IN');

CREATE TABLE customer_data_other PARTITION OF customer_data DEFAULT;

Beyond the performance angle, list partitioning by region is a common tool for data residency and compliance requirements (GDPR and similar regimes): if EU customer data must physically live within the EU, customer_data_eu can live on a tablespace backed by storage in an EU region while customer_data_us lives elsewhere, all while the application still queries one logical customer_data table.

Hash partitioning

Hash partitioning is what you reach for when there is no natural range or list boundary, but you still want the operational benefits of splitting a huge table — most often to spread write load and storage evenly, or to allow parallel maintenance operations (vacuum, reindex) to proceed independently across partitions. PostgreSQL hashes the partition key and assigns the row to one of N partitions based on (hash value) MOD N, using REMAINDER/MODULUS pairs that must partition the space exhaustively.

CREATE TABLE sessions (
    id          uuid        NOT NULL DEFAULT gen_random_uuid(),
    user_id     bigint      NOT NULL,
    started_at  timestamptz NOT NULL DEFAULT now(),
    data        jsonb,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- 4 partitions covering the full hash space: modulus 4, remainders 0..3
CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

There’s no DEFAULT partition for hash partitioning — every possible hash value is guaranteed to land in exactly one of the MODULUS/REMAINDER buckets, so there is no “leftover” case to catch. The trade-off compared to range or list partitioning is that hash partitions don’t map to anything meaningful for pruning most real queries — you rarely write WHERE user_id = 12345 and think of it as “which partition,” so hash partitioning helps operationally (smaller units to vacuum/reindex, spread I/O across tablespaces) more than it helps query latency, unless the query genuinely filters by an equality predicate on the hash key.

Key Concepts

Partition pruning

Partition pruning is the mechanism that makes all of the above worth doing: when the query planner can prove, from the WHERE clause and the partition boundaries, that a partition cannot contain any matching row, it excludes that partition from the plan entirely — no scan, no I/O, not even a consideration at execution time (with runtime pruning, this can even happen for parameterized queries and JOIN conditions discovered during execution, not just constants known at plan time).

-- Pruned: the planner knows only events_2026_06 can match, and skips every other partition
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-15';

-- NOT pruned: no filter on the partition key (created_at), every partition must be scanned
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE event_type = 'login_failed';

The critical, easy-to-miss consequence: pruning only helps if the query actually filters on the partition key. A query that filters only on event_type gains nothing from created_at-range partitioning — the planner has to scan every single partition, because any of them could contain a login_failed row. In that situation partitioning has added the overhead of extra planning and per-partition executor nodes without delivering its main benefit; a good index on event_type would help more, or partitioning by a different key would need to be considered, or (frequently the right answer) a composite approach with created_at in every query alongside the other filters.

This is exactly why it is worth pairing this note with ./10-query-planning-and-performance-tuning.md — pruning is a planner behavior, and the only reliable way to know it happened is to look at the plan:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02'
  AND tenant_id = 42;

In the EXPLAIN output, look for an Append or Merge Append node whose child list contains only the partitions you expect — if a query “on a partitioned table” is slow and the plan shows every partition being scanned under the Append, that is the partitioning equivalent of a missing index: the schema is right, but the query isn’t shaped to take advantage of it. Never assume pruning happened just because the table is partitioned — verify it every time on a new or changed query pattern.

Partition maintenance

Range-partitioned tables on a rolling time window need new partitions created before data for that period starts arriving, and old partitions retired once they age out of the retention window. Left undone, new rows either get rejected (if there’s no matching partition and no DEFAULT) or silently pile up in an unindexed-for-that-purpose DEFAULT partition, quietly defeating the whole point.

-- Create next month's partition ahead of time, before July data starts arriving
CREATE TABLE events_2026_08 PARTITION OF events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Two building blocks make this safe under load: ATTACH/DETACH PARTITION let you build or validate a partition’s data out-of-band and only briefly take a lock to attach or detach it, and CREATE TABLE ... (LIKE events INCLUDING ALL) lets you build a same-shaped table before turning it into a partition.

-- Build a partition-shaped table off to the side, load/validate its data, then attach it
CREATE TABLE events_2026_08 (LIKE events INCLUDING ALL);
-- ... bulk load or migrate data into events_2026_08 here ...
ALTER TABLE events ATTACH PARTITION events_2026_08
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- Detach a partition to archive it separately, without deleting it immediately
ALTER TABLE events DETACH PARTITION events_2024_01;

Doing this by hand every month does not scale as an operational habit — it is the kind of task that gets forgotten right up until the on-call rotation discovers there’s no partition for today. In practice, this is almost always automated, either with a scheduled job (cron, a Kubernetes CronJob, or your orchestrator of choice) running a small script that creates the next N partitions and drops/detaches ones past the retention cutoff, or with the pg_partman extension, which manages exactly this lifecycle — creation ahead of a configurable premake window, retention-based dropping or detaching, and even background migration of pre-existing unpartitioned tables into a partitioned layout.

Constraints and indexes on partitioned tables

Indexes on a partitioned table can be declared once on the parent, and PostgreSQL will propagate an equivalent index onto every existing and future partition automatically:

-- Declared once on the parent; PostgreSQL creates a matching index on every partition
CREATE INDEX idx_events_tenant_type ON events (tenant_id, event_type);

-- New partitions attached later automatically get an equivalent index too
CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
-- events_2026_09 already has idx_events_tenant_type_<suffix> at this point

Under the hood, though, there is no single physical index spanning all partitions — each partition has its own independent index built on its own data (visible in \d events_2026_09 as a distinct index object), and PostgreSQL just keeps the parent’s index definition and the set of per-partition indexes consistent as partitions come and go. This matters for maintenance: REINDEX or VACUUM work still happen per-partition, which is a big part of why partitioning helps at all — smaller, independent indexes rebuild and vacuum faster than one enormous one.

Unique constraints (and primary keys, which are just a unique constraint plus NOT NULL) have a hard requirement worth internalizing early: the partition key must be part of every unique constraint on a partitioned table. This is not a stylistic recommendation — PostgreSQL enforces uniqueness within each partition independently, so it has no way to guarantee global uniqueness of a key that doesn’t include the partition key, and it will refuse to create the constraint otherwise:

-- This FAILS: unique constraint doesn't include the partition key (created_at)
ALTER TABLE events ADD CONSTRAINT events_id_unique UNIQUE (id);
-- ERROR: unique constraint on partitioned table must include all partitioning columns

-- This SUCCEEDS: partition key included
ALTER TABLE events ADD CONSTRAINT events_id_created_unique UNIQUE (id, created_at);

In practice this means an auto-incrementing id alone can no longer be your only uniqueness guarantee on a partitioned table — you either accept a composite key that includes the partition column (as in the examples throughout this note), or you enforce global uniqueness of id some other way (an external sequence-backed generator that’s already unique by construction, like bigint GENERATED ALWAYS AS IDENTITY or a UUID, accepting that PostgreSQL itself won’t double-check it across partitions).

Sharding vs partitioning

Partitioning, as covered above, splits one logical table into multiple physical tables that all still live on a single PostgreSQL server instance. It solves problems of scan size, index size, vacuum time, and maintenance granularity, but every partition still competes for the same CPU, memory, and disk I/O of that one machine — there’s a ceiling to how much a single instance’s hardware can absorb no matter how well the table is partitioned.

Sharding solves a different problem: distributing data across multiple separate PostgreSQL server instances, so that total capacity (CPU, memory, disk, connections) scales by adding more machines rather than by making one machine bigger. Where partitioning is a first-class, built-in PostgreSQL feature, plain PostgreSQL has no native sharding — there is no CREATE TABLE ... SHARD BY that spreads rows across servers out of the box. Reaching horizontal scale-out with PostgreSQL means choosing (or combining) one of a few approaches:

The two ideas are complementary rather than competing, and production systems at scale frequently use both together: each shard (whether hand-rolled at the application layer or a Citus worker node) is itself a PostgreSQL instance holding a slice of the data — say, a range of tenant_ids — and within that instance, the tables it holds are further split with ordinary declarative partitioning by, say, created_at. Partitioning organizes data within one instance; sharding distributes it across many instances; a well-designed large-scale system usually does both, each solving the problem at the layer it’s suited for. If your reading naturally continues into the broader distributed-systems side of this (consistent hashing, replication, distributed query engines like Citus’s workers or systems entirely outside PostgreSQL such as Spark or a data warehouse), see ../data-engineer/en/10-big-data-and-distributed-computing.md.

Best Practices

Partitioning is not a default you reach for on every table above some row count — it is a deliberate trade of added operational and query-planning complexity in exchange for solving a specific, already-observed pain point. A partitioned table has more moving parts than a plain one: every additional partition adds a small amount of planning overhead (multiplied across thousands of concurrent queries, this is measurable), maintenance jobs (partition creation/retirement) become another thing that can silently fail, and constraints like the mandatory partition key in unique indexes ripple into application-level assumptions about primary keys. The right trigger to partition is evidence, not anticipation: VACUUM runs that are falling behind or taking hours, index builds or REINDEX operations that need maintenance windows, backups that are exceeding their window, or query plans that are dominated by scanning far more data than a given query logically needs. Partitioning a 2-million-row table “because it might get big” adds real complexity for zero present benefit and is a classic case of premature optimization; partitioning a 500-million-row, still-growing events table that takes 6 hours to VACUUM is close to mandatory.

When you do partition, choose the strategy that matches the actual access pattern rather than what’s structurally convenient: range partitioning by time only pays off if queries commonly filter by a time window (and it earns its keep especially strongly if there’s a retention policy that turns into DROP/DETACH instead of DELETE); list partitioning by a categorical column only helps if queries commonly filter by that category, or if there’s a hard requirement (like data residency) forcing the split regardless of query patterns; hash partitioning is the right tool specifically when there is no natural range or category to exploit and the actual goal is spreading storage and maintenance load evenly, not speeding up any particular query via pruning. Whichever strategy you pick, treat partition pruning as something to verify, not assume — run EXPLAIN (ANALYZE, BUFFERS) against your real, production-shaped queries after partitioning and confirm the Append node’s children are the partitions you expect, and revisit any query whose filters don’t happen to touch the partition key, since that query is now paying partitioning’s overhead without its benefit.

Finally, budget for the maintenance lifecycle from day one rather than as an afterthought: decide the premake window (how far ahead new partitions are created) and the retention window (how long old ones live) up front, automate both with a scheduled job or pg_partman rather than a human remembering to run a script monthly, and remember that a missing future partition is a hard failure mode (inserts either error out or land in an unindexed DEFAULT catch-all) while a missing retirement is a slow-burning one (old partitions quietly accumulate and erode the very benefit partitioning was meant to provide). If and when a single instance’s capacity is the actual bottleneck rather than any per-table characteristic, that is the signal to look at sharding — application-level or via Citus — rather than trying to solve a multi-server capacity problem by partitioning harder on one machine.

References

See also: ./08-indexing-strategies.md, ./10-query-planning-and-performance-tuning.md, ../data-engineer/en/10-big-data-and-distributed-computing.md.