Kiểu dữ liệu & Đối tượng SchemaData Types & Schema Objects
Thuộc bộ kiến thức PostgreSQL DBA Roadmap.
Tổng quan
Mỗi cluster PostgreSQL là một khu rừng các container lồng vào nhau: một database chứa nhiều schema, một schema chứa các relation (table, view, sequence, v.v.), và một relation chứa các column, mỗi column đều có kiểu dữ liệu. Hiểu được hệ thống phân cấp này — cùng với thuật ngữ đi kèm — là nền tảng cho mọi việc một DBA làm sau này: cấp quyền, thiết kế schema, viết migration, và suy luận về storage. Note này đi qua object hierarchy, các kiểu dữ liệu built-in của PostgreSQL (và đánh đổi giữa chúng), domain, constraint, ngữ nghĩa NULL, và cách schema có thể đóng vai trò như một cơ chế multi-tenancy nhẹ.
Kiến thức nền tảng
Hệ thống phân cấp đối tượng
Cluster (một server `postgres` đang chạy, một data directory)
└── Database (ví dụ "app_production")
└── Schema (namespace, ví dụ "public", "reporting", "staging")
├── Table
│ └── Column (có kiểu: integer, text, timestamptz, ...)
├── View / Materialized View
├── Sequence
├── Index
├── Function / Procedure
├── Type (domain, enum, composite)
└── Trigger (gắn vào một table)
Một kết nối client luôn bị giới hạn trong đúng một database — bạn không thể JOIN giữa các database khác nhau trong cùng một câu query (muốn làm vậy phải dùng dblink/postgres_fdw, hoặc logical replication). Tuy nhiên trong cùng một database, bạn có thể tham chiếu tự do đến các object ở schema khác nhau, đó chính là lý do schema là công cụ đúng cho việc namespacing, thay vì tách hẳn thành “mỗi concern một database.”
Vì sao schema tồn tại
Một database mới tạo mặc định có một schema tên là public. Không có gì bắt bạn phải dừng lại ở đó. Các lý do phổ biến để tạo thêm schema:
- Namespacing — tách các view
reportingkhỏi các table OLTP trongpublic, hoặc tách tablestagingdùng cho pipeline ETL, mà không cần đổi tên gì và không phải lo va chạm tên. - Cô lập quyền (privilege isolation) — cấp cho một role analytics quyền
USAGEchỉ trênreporting, và role đó thậm chí không thể thấy các table trongpublic(xem Security & Access Control để biết đầy đủ mô hình quyền, bao gồm cả default privileges theo từng schema). - Multi-tenancy nhẹ — mỗi tenant một schema trong cùng một database, sẽ bàn kỹ hơn bên dưới.
- Namespacing cho extension — các extension như PostGIS hay
pg_stat_statementsthường cài object của chúng vào một schema riêng để không làm rốipublic.
CREATE SCHEMA reporting;
CREATE SCHEMA staging AUTHORIZATION etl_user;
-- Tham chiếu đầy đủ (fully qualified):
SELECT * FROM reporting.monthly_revenue;
-- Tham chiếu không qualified sẽ được resolve qua search_path:
SHOW search_path;
-- "$user", public
SET search_path TO reporting, public;
SELECT * FROM monthly_revenue; -- giờ sẽ resolve tới reporting.monthly_revenue trước
search_path là một danh sách schema có thứ tự, theo session (hoặc theo role, qua ALTER ROLE ... SET search_path = ...), mà PostgreSQL quét qua khi một tên object không được schema-qualify. Điều này tiện lợi, nhưng trong application code và migration thì an toàn hơn nhiều nếu schema-qualify tên object một cách tường minh — một tên không qualified vô tình resolve đến “nhầm” object cùng tên ở schema khác là một loại bug kinh điển và khó phát hiện (và cũng là một vector SQL-injection đã biết đối với function SECURITY DEFINER, vì một search_path độc hại có thể chuyển hướng một lời gọi không qualified sang function do kẻ tấn công kiểm soát).
Table, row, và “tuple”
Một table là một tập hợp row có tên và có kiểu. Bên trong, PostgreSQL gọi một phiên bản vật lý của row là tuple (viết tắt của “heap tuple”). Sự phân biệt giữa row logic mà bạn SELECT ra và tuple vật lý lưu trên đĩa sẽ trở nên quan trọng khi bạn tìm hiểu về MVCC: một UPDATE trong PostgreSQL không bao giờ ghi đè tuple tại chỗ — nó ghi một tuple hoàn toàn mới và đánh dấu tuple cũ là dead. Một row mà bạn nghĩ là “một row” có thể tương ứng với nhiều phiên bản tuple đã chết tích tụ trên đĩa cho đến khi VACUUM thu hồi chúng. Toàn bộ cơ chế này được trình bày ở Storage Internals & VACUUM; hiện tại, chỉ cần nhớ rằng “row” là thuật ngữ logic/hướng người dùng còn “tuple” là thuật ngữ vật lý/storage cho cùng một thứ tại một thời điểm.
Khái niệm chính
Kiểu số (numeric types)
| Kiểu | Lưu trữ | Phạm vi / Độ chính xác | Dùng khi nào |
|---|---|---|---|
smallint | 2 byte | -32,768 đến 32,767 | bộ đếm nhỏ, cờ (flag) |
integer / int4 | 4 byte | ~-2.1B đến 2.1B | lựa chọn mặc định cho ID, count |
bigint / int8 | 8 byte | ~-9.2×10¹⁸ đến 9.2×10¹⁸ | ID với lưu lượng lớn, khi int có thể tràn |
numeric(p,s) / decimal(p,s) | biến đổi | độ chính xác tuỳ ý, chính xác tuyệt đối | tiền tệ, tính toán tài chính, bất cứ gì cần số thập phân chính xác tuyệt đối |
real / float4 | 4 byte | ~6 chữ số thập phân, xấp xỉ | dữ liệu khoa học chấp nhận được sai số làm tròn |
double precision / float8 | 8 byte | ~15 chữ số thập phân, xấp xỉ | tương tự, độ chính xác cao hơn |
serial / bigserial | 4 / 8 byte | integer + sequence tự tăng | tiện ích cũ cho auto-increment (nên dùng GENERATED ALWAYS AS IDENTITY cho code mới) |
Quy tắc quan trọng nhất: không bao giờ dùng real/double precision cho tiền. Các kiểu floating-point là xấp xỉ nhị phân — 0.1 + 0.2 không chính xác bằng 0.3 trong chuẩn IEEE 754, và sai số làm tròn đó cộng dồn qua các transaction, sổ cái (ledger), hoá đơn. numeric lưu các chữ số một cách chính xác tuyệt đối (đổi lại là phép tính chậm hơn đôi chút) và là kiểu đúng đắn cho tiền tệ, thuế suất, và bất kỳ giá trị nào mà “chính xác” quan trọng hơn “nhanh.”
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
amount numeric(12, 2) NOT NULL, -- chính xác: tối đa 10 chữ số trước, 2 chữ số sau dấu thập phân
tax_rate numeric(5, 4) NOT NULL, -- ví dụ 0.0825 cho 8.25%
created_at timestamptz NOT NULL DEFAULT now()
);
-- Minh hoạ vì sao float nguy hiểm cho tiền:
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- false!
SELECT 0.1::numeric + 0.2::numeric = 0.3::numeric; -- true
Với ID nguyên thô, mặc định dùng integer trừ khi bạn có lý do cụ thể để dự đoán table sẽ vượt quá ~2 tỷ row trong vòng đời của nó (ví dụ table event/log ghi nhiều), khi đó nên bắt đầu ngay với bigint — đổi kiểu sau này trên một table lớn, được tham chiếu nhiều nơi, là một migration đau đớn và tốn nhiều lock.
Kiểu text
PostgreSQL có ba kiểu “ký tự”, và việc chọn giữa chúng gần như luôn là vấn đề chủ đích/validation, không phải hiệu năng:
| Kiểu | Mô tả |
|---|---|
text | chuỗi độ dài không giới hạn, không kiểm tra độ dài |
varchar(n) | chuỗi độ dài biến đổi, từ chối giá trị dài hơn n |
char(n) | độ dài cố định, đệm khoảng trắng cho đủ đúng n ký tự |
Điểm đặc thù của PostgreSQL đáng ghi nhớ: text và varchar có storage nội bộ và hiệu năng giống hệt nhau — cả hai đều dùng header độ dài biến đổi cộng với dữ liệu, và không kiểu nào nhanh hơn kiểu nào khi đọc, ghi, hay đánh index. Khác biệt duy nhất mà varchar(n) thêm vào là một ràng buộc độ dài được kiểm tra tại thời điểm insert/update. Điều này khác với một số RDBMS khác nơi varchar(n) có lợi thế storage/hiệu năng thật sự so với kiểu text không giới hạn, và điều đó có nghĩa lời khuyên từng phổ biến “luôn khai báo độ dài để có hiệu năng” hoàn toàn không áp dụng trong PostgreSQL. Chỉ dùng varchar(n) khi bạn thực sự muốn database từ chối input quá dài như một quy tắc toàn vẹn dữ liệu; nếu không, text (kết hợp tuỳ chọn với ràng buộc CHECK (length(col) <= n) nếu bạn muốn giới hạn có thể thay đổi mà không cần ALTER TABLE ... TYPE) là lựa chọn mặc định hợp lý hơn. char(n) gần như không bao giờ là lựa chọn đúng trong PostgreSQL hiện đại — nó âm thầm đệm khoảng trắng ở cuối và lãng phí storage cho bất cứ gì có độ dài biến đổi; nó tồn tại chủ yếu để tương thích chuẩn SQL.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL, -- không giới hạn độ dài tuỳ tiện
username varchar(30) NOT NULL, -- quy tắc nghiệp vụ: tối đa 30 ký tự
country_code char(2) NOT NULL DEFAULT 'US' -- mã ISO độ dài cố định, một trong ít trường hợp hợp lý để dùng char(n)
);
Kiểu ngày/giờ (date/time)
| Kiểu | Lưu trữ | Ghi chú |
|---|---|---|
date | 4 byte | ngày lịch, không có giờ |
time / time with time zone | 8 / 12 byte | giờ trong ngày; biến thể có tz hiếm khi hữu ích và nói chung không được khuyến khích |
timestamp (còn gọi timestamp without time zone) | 8 byte | ngày + giờ, không có nhận thức timezone |
timestamptz (còn gọi timestamp with time zone) | 8 byte | ngày + giờ, lưu nội bộ dưới dạng UTC, hiển thị được chuyển đổi theo TimeZone của session |
interval | 16 byte | một khoảng thời gian (ví dụ '3 days 04:00:00') |
Quyết định quan trọng nhất về date/time mà một DBA PostgreSQL phải đưa ra: mặc định dùng timestamptz cho bất cứ gì hướng người dùng hoặc bất cứ gì vượt qua ranh giới timezone. Mặc dù tên gọi ngụ ý nó “lưu một timezone,” timestamptz thực ra không lưu timezone gốc chút nào — nó chuyển đổi input thành UTC nội bộ, và chuyển ngược lại thành bất kỳ timezone nào mà session đọc đang cấu hình khi xuất ra. Đây chính xác là điều bạn muốn cho các sự kiện như “đơn hàng này được đặt lúc nào” — một thời điểm duy nhất, không mập mờ, hiển thị đúng ở bất kỳ timezone địa phương nào của người xem.
timestamp (không có tz), ngược lại, lưu chính xác giá trị đồng hồ tường (wall-clock) theo nghĩa đen mà bạn đưa vào, không có khái niệm nó thuộc timezone nào. Hai server ở hai timezone khác nhau cùng insert giá trị dựa trên now() vào một column timestamp naive sẽ tạo ra các giá trị không thể so sánh trực tiếp với nhau, và không có cách nào khôi phục lại timezone dự định sau đó. Cái bẫy kinh điển: một application server chạy ở UTC và máy của developer ở Asia/Ho_Chi_Minh cùng ghi vào một column timestamp, tạo ra dữ liệu không nhất quán một cách âm thầm — không có lỗi nào xảy ra, chỉ là các con số sai lệch so với nhau.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
placed_at timestamptz NOT NULL DEFAULT now(), -- đúng: một thời điểm không mập mờ
ship_date date, -- đúng: một ngày lịch không có timezone
naive_bad timestamp -- nên tránh: mập mờ, dễ gây lỗi
);
SET TIME ZONE 'Asia/Ho_Chi_Minh';
SELECT placed_at FROM orders LIMIT 1; -- hiển thị theo ICT, bên dưới vẫn lưu là UTC
SET TIME ZONE 'UTC';
SELECT placed_at FROM orders LIMIT 1; -- cùng một row, hiển thị theo UTC — cùng một thời điểm, chỉ hiển thị khác
Trường hợp hiếm hoi hợp lý để dùng timestamp thuần là cho các giá trị thực sự không có timezone theo định nghĩa — ví dụ “thuốc này nên uống lúc 08:00 giờ địa phương bất kể bệnh nhân đi đâu.” Nếu không có lý do cụ thể như vậy, timestamptz là mặc định.
Boolean
boolean lưu TRUE, FALSE, hoặc NULL (xem phần ngữ nghĩa NULL bên dưới — một column boolean là minh hoạ rõ nhất cho logic ba giá trị). Nó chấp nhận nhiều cách viết literal khi input (true/false, 't'/'f', 'yes'/'no', 1/0) nhưng luôn chuẩn hoá về t/f khi lưu và xuất ra.
UUID
uuid lưu một định danh duy nhất toàn cục 128-bit một cách nguyên bản (16 byte, so với 36 byte nếu bạn lưu dạng chuỗi dưới dạng text). Thường dùng làm primary key khi bạn cần định danh duy nhất trên nhiều shard/service mà không cần một sequence trung tâm, hoặc khi không muốn rò rỉ thông tin tuần tự (ví dụ “chúng ta có đúng 4,812 khách hàng”). PostgreSQL có thể sinh chúng bằng gen_random_uuid() (built-in từ PG 13, trước đó cần qua pgcrypto).
CREATE TABLE api_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id bigint NOT NULL REFERENCES users(id),
token text NOT NULL
);
Đánh đổi: UUID lớn hơn bigint, và UUID ngẫu nhiên (v4) có locality kém trong index — các lần insert rơi vào các vị trí ngẫu nhiên trong B-tree, làm giảm hiệu quả cache và gây bloat cho index, so với một bigint tăng dần đơn điệu. Nếu cần UUID nhưng vẫn quan tâm đến insert locality, hãy cân nhắc sinh UUID v7 (có thứ tự theo thời gian).
JSON và JSONB
PostgreSQL có hai kiểu JSON: json lưu đúng nguyên văn text bạn đã insert (giữ nguyên khoảng trắng và thứ tự key, và nó parse/validate lại mỗi lần đọc), trong khi jsonb lưu một biểu diễn nhị phân đã được decompose (không giữ khoảng trắng/thứ tự, nhưng xử lý nhanh hơn và quan trọng là có thể đánh index bằng GIN cho các query containment @>, kiểm tra tồn tại key ?, và path query #>>). Trừ khi bạn có lý do cụ thể để giữ nguyên định dạng text gốc, jsonb gần như luôn là lựa chọn đúng.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL
);
CREATE INDEX idx_events_payload ON events USING gin (payload);
SELECT * FROM events WHERE payload @> '{"type": "signup"}';
Array
Khác với đa số database quan hệ, PostgreSQL hỗ trợ native array column cho bất kỳ base type nào — một tính năng thực sự hiếm gặp ở các RDBMS. Điều này tiện lợi cho các danh sách nhỏ, denormalized (ví dụ tag), nhưng không nên thay thế một child table đúng nghĩa khi danh sách cần được index độc lập, có foreign key, hay có constraint riêng cho từng phần tử.
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}'
);
INSERT INTO articles (tags) VALUES (ARRAY['postgres', 'dba', 'sql']);
SELECT * FROM articles WHERE 'postgres' = ANY(tags);
CREATE INDEX idx_articles_tags ON articles USING gin (tags);
Kiểu network và geometric (đề cập ngắn gọn)
PostgreSQL cũng có sẵn các kiểu cho địa chỉ mạng — inet (host + subnet tuỳ chọn), cidr (chỉ network, từ chối host bit), macaddr — và hình học — point, line, box, polygon, circle — cùng với range type (int4range, tsrange, daterange, v.v.) được dùng nhiều với ràng buộc EXCLUDE bên dưới. Đáng để biết chúng tồn tại; hãy dùng khi domain thực sự cần suy luận về IP/mạng hoặc hình học, thay vì mô hình hoá chúng bằng text thuần.
Domain
Một domain là một kiểu do người dùng định nghĩa, xây dựng trên một base type cùng với một default tuỳ chọn và một tập ràng buộc CHECK tuỳ chọn đi kèm. Giá trị của domain nằm ở khả năng tái sử dụng: thay vì lặp lại cùng một mệnh đề CHECK (value > 0) trên mọi column quantity ở hàng chục table, bạn định nghĩa nó một lần và tham chiếu domain đó làm kiểu column ở mọi nơi.
CREATE DOMAIN positive_int AS integer
CHECK (VALUE > 0);
CREATE DOMAIN email_address AS text
CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
quantity positive_int NOT NULL,
unit_price numeric(10, 2) NOT NULL
);
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email email_address NOT NULL UNIQUE
);
INSERT INTO order_items (order_id, quantity, unit_price) VALUES (1, -5, 9.99);
-- ERROR: value for domain positive_int violates check constraint
Ngoài việc tái sử dụng validation, domain còn tập trung hoá việc thay đổi: siết chặt hoặc nới lỏng quy tắc (ALTER DOMAIN positive_int ADD CONSTRAINT ...) sẽ áp dụng cho mọi column đang dùng domain đó, mà không cần tìm và sửa từng mệnh đề CHECK trên từng table riêng lẻ. Đánh đổi là một lớp gián tiếp — người đọc schema phải tra định nghĩa domain để hiểu ràng buộc thực sự của column, vì vậy domain phù hợp nhất cho các quy tắc validation thực sự xuyên suốt nhiều nơi, không phải các check riêng lẻ chỉ dùng cho một table (nơi một CHECK inline dễ nhận biết hơn).
Constraint
Constraint là cách PostgreSQL thực thi tính toàn vẹn dữ liệu ở tầng database, độc lập với application code.
NOT NULL từ chối NULL cho một column — constraint đơn giản và phổ biến nhất.
UNIQUE đảm bảo không có hai row nào chia sẻ cùng một giá trị (hoặc tổ hợp giá trị, với UNIQUE nhiều column) — được hậu thuẫn nội bộ bởi một unique index. Lưu ý UNIQUE cho phép nhiều NULL, vì theo ngữ nghĩa NULL, không có hai NULL nào được coi là bằng nhau.
PRIMARY KEY là UNIQUE + NOT NULL kết hợp, và chỉ định (các) column định danh chính thức của table. Một table có tối đa một primary key nhưng có thể có bất kỳ số lượng ràng buộc UNIQUE nào.
FOREIGN KEY thực thi việc giá trị của một column phải tồn tại trong key của một table được tham chiếu, duy trì tính toàn vẹn tham chiếu. Các mệnh đề ON DELETE/ON UPDATE kiểm soát điều gì xảy ra với row tham chiếu khi row được tham chiếu bị xoá hoặc key của nó thay đổi:
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES customers(id)
ON DELETE RESTRICT -- từ chối xoá một customer còn đơn hàng
ON UPDATE CASCADE, -- nếu id của customer thay đổi, cập nhật theo
total numeric(12,2) NOT NULL
);
CREATE TABLE order_notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint
REFERENCES orders(id)
ON DELETE SET NULL, -- giữ lại note, chỉ gỡ liên kết, nếu order bị xoá
note text NOT NULL
);
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADE, -- xoá order sẽ xoá luôn các dòng chi tiết
sku text NOT NULL
);
| Hành động | Hành vi khi row được tham chiếu bị xoá/cập nhật |
|---|---|
CASCADE | lan truyền việc xoá/cập nhật xuống các row tham chiếu |
RESTRICT | từ chối xoá/cập nhật nếu còn row tham chiếu tồn tại (kiểm tra ngay lập tức) |
NO ACTION (mặc định) | giống RESTRICT nhưng việc kiểm tra có thể được hoãn đến cuối transaction |
SET NULL | đặt (các) column tham chiếu thành NULL |
SET DEFAULT | đặt (các) column tham chiếu về giá trị default của nó |
Chọn CASCADE cho các mối quan hệ sở hữu thực sự (dòng chi tiết thuộc về một order — xoá order nên xoá luôn chúng), RESTRICT/NO ACTION khi việc xoá nhầm sẽ nguy hiểm và bạn muốn con người phải dọn dẹp các dependent một cách tường minh trước, và SET NULL khi record con nên tiếp tục tồn tại độc lập sau khi cha bị xoá.
CHECK validate một biểu thức boolean bất kỳ trên một hoặc nhiều column của một row:
ALTER TABLE order_items
ADD CONSTRAINT positive_quantity CHECK (quantity > 0);
ALTER TABLE events
ADD CONSTRAINT valid_date_range CHECK (ends_at > starts_at);
EXCLUDE (đặc thù PostgreSQL, nâng cao) tổng quát hoá tính duy nhất cho các toán tử bất kỳ, thường dùng cùng range type và GiST index để ngăn các khoảng chồng lấn — ví dụ, không cho phép hai booking cùng một phòng chồng lấn thời gian:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
INSERT INTO room_bookings (room_id, during)
VALUES (1, '[2026-07-19 09:00, 2026-07-19 10:00)');
INSERT INTO room_bookings (room_id, during)
VALUES (1, '[2026-07-19 09:30, 2026-07-19 10:30)');
-- ERROR: conflicting key value violates exclusion constraint
Có thể đọc là “từ chối bất kỳ row mới nào mà room_id bằng room_id của một row đã tồn tại và during chồng lấn (&&) với during của row đó” — một ràng buộc mà UNIQUE hay CHECK thuần tuý không thể diễn đạt, vì nó phụ thuộc vào việc so sánh với các row khác thay vì chỉ validate một row đơn lẻ.
Ngữ nghĩa NULL
NULL biểu diễn “không xác định” hoặc “vắng mặt,” không phải số không, không phải chuỗi rỗng, và không phải bất kỳ giá trị cụ thể nào khác. Chính sự thật đơn giản này dẫn đến khá nhiều hành vi gây bối rối cho người mới.
PostgreSQL (theo chuẩn SQL) dùng logic ba giá trị (three-valued logic): bất kỳ biểu thức boolean nào cũng evaluate thành TRUE, FALSE, hoặc UNKNOWN. So sánh bất cứ gì với NULL bằng = cho ra UNKNOWN (hiển thị là NULL), bởi vì “giá trị không xác định này có bằng giá trị không xác định kia không” tự bản thân nó cũng là không xác định — bao gồm cả NULL = NULL, kết quả không phải là TRUE:
SELECT NULL = NULL; -- NULL (tức là unknown)
SELECT NULL = 1; -- NULL
SELECT NULL IS NULL; -- true — IS NULL là một toán tử riêng, không phải `=`
SELECT NULL IS NOT NULL; -- false
Đây chính là lý do SQL cung cấp IS NULL / IS NOT NULL như các toán tử đặc biệt thay vì kỳ vọng = NULL hoạt động — chúng kiểm tra trực tiếp dấu hiệu “vắng mặt” thay vì thực hiện so sánh giá trị. Một mệnh đề WHERE column = NULL là một bug phổ biến: nó không bao giờ khớp với row nào (kể cả row có column là NULL), vì điều kiện evaluate thành UNKNOWN, và WHERE chỉ giữ lại row có điều kiện là TRUE.
Tác động của NULL lên aggregate cũng là một bất ngờ thường gặp: COUNT(*) đếm row bất kể nội dung, trong khi COUNT(column) chỉ đếm các row mà column IS NOT NULL. Tương tự, SUM, AVG, MIN, và MAX đều âm thầm bỏ qua giá trị NULL thay vì coi chúng là số không.
CREATE TABLE scores (id int, points int);
INSERT INTO scores VALUES (1, 10), (2, NULL), (3, 30);
SELECT COUNT(*) FROM scores; -- 3
SELECT COUNT(points) FROM scores; -- 2 (row 2 bị loại)
SELECT SUM(points) FROM scores; -- 40 (NULL không đóng góp gì, không tính là 0)
SELECT AVG(points) FROM scores; -- 20 (trung bình của 10 và 30, KHÔNG phải (10+0+30)/3)
Trong join, một INNER JOIN loại bỏ bất kỳ row nào có điều kiện join evaluate thành UNKNOWN (ví dụ, join trên một foreign key column là NULL sẽ không tìm thấy match nào và row đó bị loại), đó chính xác là lý do LEFT JOIN tồn tại — để giữ lại row bên trái và điền NULL cho các column bên phải khi không tìm thấy match. Việc lọc sau một LEFT JOIN bằng một WHERE right_table.col = 'x' thông thường sẽ âm thầm biến nó trở lại thành inner join, vì các row không match có right_table.col IS NULL và phép so sánh evaluate thành UNKNOWN; cách khắc phục là chuyển điều kiện vào mệnh đề ON, hoặc thêm tường minh OR right_table.col IS NULL.
Best Practices
- Mặc định các primary key kiểu số nguyên mới nên dùng
GENERATED ALWAYS AS IDENTITYthay vì pseudo-typeserialcũ; nó tích hợp tốt hơn với hệ thống quyền và chuẩn SQL. - Dùng
numericcho bất kỳ giá trị nào đại diện cho tiền, thuế, hoặc bất cứ gì cần số học thập phân chính xác tuyệt đối — không bao giờ dùngreal/double precision. - Ưu tiên
texthơnvarchar(n)/char(n)trừ khi giới hạn độ dài là một quy tắc nghiệp vụ thực sự mà bạn muốn database thực thi; nhớ rằng không có khác biệt hiệu năng nào biện minh cho việc giới hạn độ dài “cho chắc.” - Mặc định dùng
timestamptzcho mọi column timestamp trừ khi bạn có lý do cụ thể, chủ đích để lưu giá trị wall-clock không có timezone; rà soát các columntimestampcũ để tìm bug xuyên timezone. - Ưu tiên
jsonbhơnjsontrừ khi bạn cần giữ nguyên định dạng/khoảng trắng gốc; đánh index bằng GIN khi bạn truy vấn vào cấu trúc bên trong. - Dùng domain để tập trung hoá logic validation lặp lại (ví dụ định dạng email, số dương) trên nhiều table thay vì copy-paste cùng một mệnh đề
CHECKở mọi nơi. - Chọn hành động
ON DELETEcủaFOREIGN KEYmột cách chủ đích cho từng mối quan hệ —CASCADEcho sở hữu,RESTRICT/NO ACTIONcho các tham chiếu quan trọng về an toàn,SET NULLcho tham chiếu tuỳ chọn/có thể tách rời — thay vì mặc địnhNO ACTIONtheo thói quen. - Không bao giờ viết
WHERE column = NULL; luôn dùngIS [NOT] NULL. Khi viết outer join, đặt điều kiện lọc trên table được outer-join trong mệnh đềON, không phảiWHERE, trừ khi bạn cố ý muốn biến nó trở lại thành inner join. - Dùng thêm schema (ngoài
public) để tách biệt các concern — view reporting, table staging, dữ liệu theo từng tenant — và kết hợp với quyền theo phạm vi schema (xem Security & Access Control) thay vì chỉ dựa vào quy ước đặt tên để cô lập. - Schema-qualify tên object trong migration, view, và function
SECURITY DEFINERthay vì dựa vàosearch_path, để tránh cả bug về tính đúng đắn lẫn vấn đề bảo mật kiểu search-path-hijacking. - Chỉ cân nhắc bố cục schema-per-tenant cho multi-tenancy khi số lượng tenant vừa phải (hàng chục đến vài nghìn) và query xuyên tenant hiếm khi xảy ra — xem Schema Design Patterns & Antipatterns để so sánh đầy đủ hơn với multi-tenancy theo row và database-per-tenant.
Tài liệu tham khảo
Part of the PostgreSQL DBA Roadmap knowledge base.
Overview
Every PostgreSQL cluster is a forest of containers nested inside one another: a database holds schemas, a schema holds relations (tables, views, sequences, and more), and a relation holds columns, each of which is typed. Understanding this hierarchy — and the vocabulary that goes with it — is the foundation for everything else a DBA does: granting privileges, designing schemas, writing migrations, and reasoning about storage. This note walks through the object hierarchy, PostgreSQL’s built-in data types (and the tradeoffs behind choosing one over another), domains, constraints, NULL semantics, and how schemas double as a lightweight multi-tenancy mechanism.
Fundamentals
The object hierarchy
Cluster (a running `postgres` server, one data directory)
└── Database (e.g. "app_production")
└── Schema (namespace, e.g. "public", "reporting", "staging")
├── Table
│ └── Column (typed: integer, text, timestamptz, ...)
├── View / Materialized View
├── Sequence
├── Index
├── Function / Procedure
├── Type (domain, enum, composite)
└── Trigger (attached to a table)
A single client connection is always scoped to exactly one database — you cannot JOIN across databases in the same query (that requires dblink/postgres_fdw, or logical replication). Within a database, however, you can freely reference objects in different schemas, which is what makes schemas the right tool for namespacing rather than reaching for “one database per concern.”
Why schemas exist
A fresh database ships with a single schema called public. Nothing forces you to stop there. Common reasons to add more:
- Namespacing — separate
reportingviews from thepublicOLTP tables, orstagingtables used by an ETL pipeline, without renaming anything or worrying about collisions. - Privilege isolation — grant an analytics role
USAGEonreportingonly, and it can never even see tables inpublic(see Security & Access Control for the full privilege model, including default privileges per schema). - Lightweight multi-tenancy — one schema per tenant inside a single database, discussed below.
- Extension namespacing — extensions like PostGIS or
pg_stat_statementsoften install their objects into a dedicated schema to avoid clutteringpublic.
CREATE SCHEMA reporting;
CREATE SCHEMA staging AUTHORIZATION etl_user;
-- Fully qualified reference:
SELECT * FROM reporting.monthly_revenue;
-- Unqualified reference resolves via search_path:
SHOW search_path;
-- "$user", public
SET search_path TO reporting, public;
SELECT * FROM monthly_revenue; -- now resolves to reporting.monthly_revenue first
search_path is a per-session (or per-role, via ALTER ROLE ... SET search_path = ...) ordered list of schemas PostgreSQL scans when a name isn’t schema-qualified. It is convenient, but in application code and migrations it is safer to schema-qualify object names explicitly — an unqualified name resolving to the “wrong” same-named object in a different schema is a classic, hard-to-spot bug (and a known SQL-injection vector for SECURITY DEFINER functions, since a malicious search_path can redirect an unqualified call to an attacker-controlled function).
Tables, rows, and the “tuple”
A table is a named, typed collection of rows. Internally, PostgreSQL calls a physical row version a tuple (short for “heap tuple”). This distinction between the logical row you SELECT and the physical tuple stored on disk matters once you get to MVCC: an UPDATE in PostgreSQL never overwrites a tuple in place — it writes a brand-new tuple and marks the old one dead. A row you think of as “one row” may correspond to many dead tuple versions accumulating on disk until VACUUM reclaims them. That whole mechanism is covered in Storage Internals & VACUUM; for now, just remember that “row” is the logical/user-facing term and “tuple” is the physical/storage term for the same thing at a point in time.
Key Concepts
Numeric types
| Type | Storage | Range / Precision | Typical use |
|---|---|---|---|
smallint | 2 bytes | -32,768 to 32,767 | small counters, flags |
integer / int4 | 4 bytes | ~-2.1B to 2.1B | default choice for IDs, counts |
bigint / int8 | 8 bytes | ~-9.2×10¹⁸ to 9.2×10¹⁸ | high-volume IDs, when int might overflow |
numeric(p,s) / decimal(p,s) | variable | arbitrary precision, exact | money, financial math, anything requiring exact decimal arithmetic |
real / float4 | 4 bytes | ~6 decimal digits, approximate | scientific data where rounding is acceptable |
double precision / float8 | 8 bytes | ~15 decimal digits, approximate | same, more precision |
serial / bigserial | 4 / 8 bytes | integer + auto-increment sequence | legacy convenience for auto-increment (prefer GENERATED ALWAYS AS IDENTITY in new code) |
The critical rule: never use real/double precision for money. Floating-point types are binary approximations — 0.1 + 0.2 is not exactly 0.3 in IEEE 754, and that rounding error compounds across transactions, ledgers, and invoices. numeric stores digits exactly (at the cost of somewhat slower arithmetic) and is the correct type for currency, tax rates, and any value where “exact” matters more than “fast.”
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
amount numeric(12, 2) NOT NULL, -- exact: up to 10 digits before, 2 after the decimal point
tax_rate numeric(5, 4) NOT NULL, -- e.g. 0.0825 for 8.25%
created_at timestamptz NOT NULL DEFAULT now()
);
-- Demonstrating why float is dangerous for money:
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- false!
SELECT 0.1::numeric + 0.2::numeric = 0.3::numeric; -- true
For raw integer IDs, default to integer unless you have concrete reason to expect the table to exceed ~2 billion rows over its lifetime (high-write event/log tables, for instance), in which case start with bigint — changing the type later on a large, referenced table is a painful, lock-heavy migration.
Text types
PostgreSQL has three “character” types, and the choice between them is almost always about intent and validation, not performance:
| Type | Description |
|---|---|
text | unlimited-length string, no length check |
varchar(n) | variable-length string, rejects values longer than n |
char(n) | fixed-length, blank-padded to exactly n characters |
The PostgreSQL-specific nuance worth internalizing: text and varchar have identical internal storage and identical performance — both use a variable-length header plus the data, and neither is faster than the other for reads, writes, or indexing. The only difference varchar(n) adds is a length constraint enforced at insert/update time. This is unlike some other RDBMSes where varchar(n) has a real storage/performance advantage over an unbounded text type, and it means the once-common advice “always specify a length for performance” simply does not apply in PostgreSQL. Use varchar(n) only when you genuinely want the database to reject overlong input as a matter of data integrity; otherwise text (optionally paired with a CHECK (length(col) <= n) constraint if you want the limit to be changeable without an ALTER TABLE ... TYPE) is the more idiomatic default. char(n) is essentially never the right choice in modern PostgreSQL — it silently pads with trailing spaces and wastes storage for anything of variable length; it survives mostly for SQL-standard compatibility.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL, -- no arbitrary length cap
username varchar(30) NOT NULL, -- business rule: max 30 chars
country_code char(2) NOT NULL DEFAULT 'US' -- fixed-width ISO code, rare legitimate use of char(n)
);
Date/time types
| Type | Storage | Notes |
|---|---|---|
date | 4 bytes | calendar date, no time |
time / time with time zone | 8 / 12 bytes | time of day; the tz variant is rarely useful and generally discouraged |
timestamp (aka timestamp without time zone) | 8 bytes | date + time, no timezone awareness |
timestamptz (aka timestamp with time zone) | 8 bytes | date + time, stored internally as UTC, displayed converted to the session’s TimeZone setting |
interval | 16 bytes | a span of time (e.g. '3 days 04:00:00') |
The single most important date/time decision a PostgreSQL DBA makes: default to timestamptz for anything user-facing or anything that crosses a timezone boundary. Despite the name implying it “stores a timezone,” timestamptz does not store the original timezone at all — it converts the input to UTC internally, and converts back to whatever timezone the reading session has configured on output. This is exactly what you want for events like “when did this order get placed” — a single unambiguous instant in time that renders correctly in any viewer’s local timezone.
timestamp (no tz), by contrast, stores exactly the literal wall-clock value you gave it with no notion of which timezone it was in. Two servers in different timezones inserting now()-derived values into a naive timestamp column will produce values that are not directly comparable, and there’s no way to recover which zone was intended after the fact. The classic footgun: an application server in UTC and developers’ laptops in Asia/Ho_Chi_Minh both writing to a timestamp column produce silently inconsistent data — nothing errors, the numbers are just wrong relative to each other.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
placed_at timestamptz NOT NULL DEFAULT now(), -- correct: unambiguous instant
ship_date date, -- correct: a calendar date has no timezone
naive_bad timestamp -- avoid: ambiguous, footgun-prone
);
SET TIME ZONE 'Asia/Ho_Chi_Minh';
SELECT placed_at FROM orders LIMIT 1; -- displayed in ICT, stored as UTC underneath
SET TIME ZONE 'UTC';
SELECT placed_at FROM orders LIMIT 1; -- same row, displayed in UTC — same instant, different rendering
The rare legitimate use of plain timestamp is for values that are genuinely timezone-less by definition — e.g., “this medication should be taken at 08:00 local wall-clock time regardless of where the patient travels.” Absent such a specific reason, timestamptz is the default.
Boolean
boolean stores TRUE, FALSE, or NULL (see the NULL semantics section below — a boolean column is the clearest illustration of three-valued logic). It accepts a variety of literal spellings on input (true/false, 't'/'f', 'yes'/'no', 1/0) but always normalizes to t/f in storage and output.
UUID
uuid stores a 128-bit universally unique identifier natively (16 bytes, versus 36 bytes if you stored the string form as text). Common as a primary key when you need identifiers that are unique across shards/services without a central sequence, or that shouldn’t leak sequential information (e.g., “we have exactly 4,812 customers”). PostgreSQL can generate them with gen_random_uuid() (built in since PG 13, via pgcrypto before that).
CREATE TABLE api_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id bigint NOT NULL REFERENCES users(id),
token text NOT NULL
);
The tradeoff: UUIDs are larger than bigint, and random (v4) UUIDs make poor index locality — insertions land at random points in a B-tree, hurting cache behavior and causing index bloat, compared to a monotonically increasing bigint. If you need UUIDs but care about insert locality, consider UUID v7 (time-ordered) generation.
JSON and JSONB
PostgreSQL has two JSON types: json stores the exact text you inserted (preserving whitespace and key order, and it re-parses/validates on every read), while jsonb stores a decomposed binary representation (no whitespace/order preservation, but faster to process and, critically, indexable with GIN indexes for containment queries @>, key existence ?, and path queries #>>). Unless you have a specific reason to preserve the original text formatting, jsonb is almost always the right choice.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL
);
CREATE INDEX idx_events_payload ON events USING gin (payload);
SELECT * FROM events WHERE payload @> '{"type": "signup"}';
Arrays
Unlike most relational databases, PostgreSQL supports native array columns of any base type — a genuinely rare RDBMS feature. This is convenient for small, denormalized lists (e.g., tags) but should not replace a proper child table when the list needs independent indexing, foreign keys, or per-element constraints.
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}'
);
INSERT INTO articles (tags) VALUES (ARRAY['postgres', 'dba', 'sql']);
SELECT * FROM articles WHERE 'postgres' = ANY(tags);
CREATE INDEX idx_articles_tags ON articles USING gin (tags);
Network and geometric types (brief mention)
PostgreSQL also ships built-in types for network addressing — inet (host + optional subnet), cidr (network only, rejects host bits), macaddr — and geometry — point, line, box, polygon, circle — plus range types (int4range, tsrange, daterange, etc.) used heavily with EXCLUDE constraints below. These are worth knowing exist; reach for them when the domain genuinely calls for IP/network or geometric reasoning rather than modeling those as plain text.
Domains
A domain is a user-defined type built on top of a base type with an optional default and an optional set of CHECK constraints attached. The value of a domain is reuse: instead of repeating the same CHECK (value > 0) clause on every quantity column across a dozen tables, define it once and reference the domain as the column type everywhere.
CREATE DOMAIN positive_int AS integer
CHECK (VALUE > 0);
CREATE DOMAIN email_address AS text
CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
quantity positive_int NOT NULL,
unit_price numeric(10, 2) NOT NULL
);
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email email_address NOT NULL UNIQUE
);
INSERT INTO order_items (order_id, quantity, unit_price) VALUES (1, -5, 9.99);
-- ERROR: value for domain positive_int violates check constraint
Beyond validation reuse, domains also centralize change: tightening or loosening the rule (ALTER DOMAIN positive_int ADD CONSTRAINT ...) propagates to every column using it, without hunting down and editing a CHECK clause on each table individually. The tradeoff is a layer of indirection — a schema reader has to go look up the domain definition to understand a column’s real constraints, so domains are best used for genuinely cross-cutting validation rules, not one-off, single-table checks (where an inline CHECK is more discoverable).
Constraints
Constraints are how PostgreSQL enforces data integrity at the database level, independent of application code.
NOT NULL rejects NULL for a column — the simplest and most common constraint.
UNIQUE guarantees no two rows share the same value (or combination of values, for a multi-column UNIQUE) — backed internally by a unique index. Note that UNIQUE allows multiple NULLs, since by NULL semantics no two NULLs are considered equal to each other.
PRIMARY KEY is UNIQUE + NOT NULL combined, and designates the table’s canonical identifying column(s). A table can have at most one primary key but any number of UNIQUE constraints.
FOREIGN KEY enforces that a column’s value must exist in a referenced table’s key, maintaining referential integrity. The ON DELETE/ON UPDATE clauses control what happens to the referencing row when the referenced row is deleted or its key changes:
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES customers(id)
ON DELETE RESTRICT -- refuse to delete a customer that still has orders
ON UPDATE CASCADE, -- if a customer's id ever changes, follow it
total numeric(12,2) NOT NULL
);
CREATE TABLE order_notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint
REFERENCES orders(id)
ON DELETE SET NULL, -- keep the note, just detach it, if the order is deleted
note text NOT NULL
);
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL
REFERENCES orders(id)
ON DELETE CASCADE, -- deleting an order deletes its line items too
sku text NOT NULL
);
| Action | Behavior on delete/update of the referenced row |
|---|---|
CASCADE | propagate the delete/update to referencing rows |
RESTRICT | refuse the delete/update if referencing rows exist (checked immediately) |
NO ACTION (default) | same as RESTRICT but checkable can be deferred to end of transaction |
SET NULL | set the referencing column(s) to NULL |
SET DEFAULT | set the referencing column(s) to their default value |
Choose CASCADE for true ownership relationships (line items belong to an order — deleting the order should delete them), RESTRICT/NO ACTION when accidental deletion would be dangerous and you want a human to explicitly clean up dependents first, and SET NULL when the child record should outlive its parent in a detached state.
CHECK constraints validate an arbitrary boolean expression over one or more columns of a row:
ALTER TABLE order_items
ADD CONSTRAINT positive_quantity CHECK (quantity > 0);
ALTER TABLE events
ADD CONSTRAINT valid_date_range CHECK (ends_at > starts_at);
EXCLUDE constraints (PostgreSQL-specific, advanced) generalize uniqueness to arbitrary operators, most commonly used together with range types and a GiST index to prevent overlapping ranges — e.g., no two bookings for the same room may overlap in time:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
INSERT INTO room_bookings (room_id, during)
VALUES (1, '[2026-07-19 09:00, 2026-07-19 10:00)');
INSERT INTO room_bookings (room_id, during)
VALUES (1, '[2026-07-19 09:30, 2026-07-19 10:30)');
-- ERROR: conflicting key value violates exclusion constraint
This reads as “reject any new row where room_id equals an existing row’s room_id and during overlaps (&&) an existing row’s during” — a constraint no plain UNIQUE or CHECK could express, since it depends on comparing against other rows rather than validating a single row in isolation.
NULL semantics
NULL represents “unknown” or “absent,” not zero, not an empty string, and not any other concrete value. This single fact drives a surprising number of behaviors that trip up newcomers.
PostgreSQL (following the SQL standard) uses three-valued logic: any boolean expression evaluates to TRUE, FALSE, or UNKNOWN. Comparing anything to NULL with = yields UNKNOWN (displayed as NULL), because “is this unknown value equal to that unknown value” is itself unknown — including NULL = NULL, which is not TRUE:
SELECT NULL = NULL; -- NULL (i.e. unknown)
SELECT NULL = 1; -- NULL
SELECT NULL IS NULL; -- true — IS NULL is a dedicated operator, not `=`
SELECT NULL IS NOT NULL; -- false
This is exactly why SQL provides IS NULL / IS NOT NULL as special-cased operators rather than expecting = NULL to work — they test for the absence-marker directly rather than performing a value comparison. A WHERE column = NULL clause is a common bug: it never matches any row (including rows where column is NULL), because the condition evaluates to UNKNOWN, and WHERE only keeps rows where the condition is TRUE.
NULL’s effect on aggregates is another frequent surprise: COUNT(*) counts rows regardless of content, while COUNT(column) counts only rows where column IS NOT NULL. Similarly, SUM, AVG, MIN, and MAX all silently ignore NULL values rather than treating them as zero.
CREATE TABLE scores (id int, points int);
INSERT INTO scores VALUES (1, 10), (2, NULL), (3, 30);
SELECT COUNT(*) FROM scores; -- 3
SELECT COUNT(points) FROM scores; -- 2 (row 2 excluded)
SELECT SUM(points) FROM scores; -- 40 (NULL contributes nothing, not treated as 0)
SELECT AVG(points) FROM scores; -- 20 (average of 10 and 30, NOT (10+0+30)/3)
In joins, an INNER JOIN drops any row whose join condition evaluates to UNKNOWN (e.g., joining on a NULL foreign key column finds no match and the row is excluded), which is precisely why LEFT JOIN exists — to preserve the left-side row and fill the right-side columns with NULL when no match is found. Filtering after a LEFT JOIN with a plain WHERE right_table.col = 'x' silently turns it back into an inner join, because unmatched rows have right_table.col IS NULL and the comparison evaluates to UNKNOWN; the fix is either moving the condition into the ON clause or explicitly adding OR right_table.col IS NULL.
Best Practices
- Default new integer primary keys to
GENERATED ALWAYS AS IDENTITYrather than the legacyserialpseudo-type; it integrates better with privileges and the SQL standard. - Use
numericfor any value representing money, tax, or anything requiring exact decimal arithmetic — neverreal/double precision. - Prefer
textovervarchar(n)/char(n)unless a length cap is a genuine business rule you want enforced by the database; remember there is no performance difference to justify capping length “just in case.” - Default to
timestamptzfor all timestamp columns unless you have a specific, deliberate reason to store a timezone-naive wall-clock value; audit any legacytimestampcolumns for cross-timezone bugs. - Reach for
jsonboverjsonunless you must preserve original formatting/whitespace; index it with GIN when you query into its structure. - Use domains to centralize repeated validation logic (e.g., email format, positive amounts) across multiple tables rather than copy-pasting the same
CHECKclause everywhere. - Choose
FOREIGN KEYON DELETEactions deliberately per relationship —CASCADEfor ownership,RESTRICT/NO ACTIONfor safety-critical references,SET NULLfor optional/detachable references — rather than defaulting toNO ACTIONout of habit. - Never write
WHERE column = NULL; always useIS [NOT] NULL. When writing outer joins, put filters on the outer-joined table in theONclause, not theWHEREclause, unless you specifically intend to collapse it back to an inner join. - Use additional schemas (beyond
public) to separate concerns — reporting views, staging tables, per-tenant data — and pair them with schema-scoped privileges (see Security & Access Control) rather than relying on naming conventions alone for isolation. - Schema-qualify object names in migrations, views, and
SECURITY DEFINERfunctions rather than relying onsearch_path, to avoid both correctness bugs and search-path-hijacking security issues. - Consider a schema-per-tenant layout for multi-tenancy only when tenant counts are moderate (tens to low thousands) and cross-tenant queries are rare — see Schema Design Patterns & Antipatterns for the fuller comparison against row-level multi-tenancy and database-per-tenant.