PostgreSQL

Cú pháp PostgreSQL cơ bản

Kiểu dữ liệu, DDL, DML, UPSERT (ON CONFLICT), RETURNING, GROUP BY ALL, FOR PORTION OF — gốc cho PostgreSQL 18 & 19.

1. Kiểu dữ liệu — đa dạng hơn SQL Server

Numeric

KiểuRangeStorageKhi nào
SMALLINT-32K → 32K2 bytesSố nhỏ
INTEGER / INT-2.1B → 2.1B4 bytesID nhỏ
BIGINT±9.2 × 10¹⁸8 bytesID lớn, timestamp
DECIMAL(p, s) / NUMERIC(p, s)Chính xácVariableTiền
REALFloat 32-bit4 bytesKhoa học
DOUBLE PRECISIONFloat 64-bit8 bytesKhoa học
SERIAL / BIGSERIALAuto-increment4/8Legacy — giờ ưu tiên IDENTITY
-- Postgres recommend cú pháp IDENTITY chuẩn ANSI hơn SERIAL
CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(18, 2) NOT NULL CHECK (price >= 0)
);

Chuỗi

KiểuGhi chú
CHAR(n)Cố định n char, pad space. Hiếm dùng.
VARCHAR(n)Giới hạn n. Không nhanh hơn TEXT ở Postgres.
TEXTKhông giới hạn. Mặc định nên dùng.
Câu PV đặc thù Postgres:"VARCHAR(255) vs TEXT — chọn cái nào?"

"Khác SQL Server/MySQL, Postgres không phân biệt performance giữa VARCHAR(n)TEXT. Cả 2 đều lưu cùng cách (TOAST cho data lớn). Dùng VARCHAR(n) chỉ để enforce length limit ở DB. Còn không thì TEXT cho linh hoạt."

Thời gian

KiểuÝ nghĩa
DATENgày, không giờ
TIMEGiờ, không ngày
TIMESTAMPNgày + giờ, không timezone
TIMESTAMPTZNgày + giờ + timezone, lưu UTC. Mặc định nên dùng.
INTERVALKhoảng thời gian
Best practice 2025: luôn TIMESTAMPTZ cho mọi cột thời gian user-facing. Postgres lưu UTC, convert theo session timezone khi đọc.
SET TIMEZONE TO 'Asia/Ho_Chi_Minh';
SELECT NOW();   -- output sẽ ở +07:00

Kiểu Postgres đặc trưng

-- Array
CREATE TABLE posts (
    id BIGSERIAL PRIMARY KEY,
    title TEXT,
    tags TEXT[]                            -- array of TEXT
);
INSERT INTO posts (title, tags) VALUES ('Hello', ARRAY['vue', 'nuxt']);
SELECT * FROM posts WHERE 'vue' = ANY(tags);

-- JSON / JSONB (JSONB nén nhị phân, query nhanh, index được)
CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    payload JSONB NOT NULL
);
INSERT INTO events (payload)
VALUES ('{"type": "click", "user": "alice", "tags": ["promo"]}');

SELECT payload->>'type' AS event_type,
       payload->'user' AS user_json
FROM events;

-- UUID — Postgres 18 có uuidv7() built-in
CREATE TABLE accounts (
    id UUID PRIMARY KEY DEFAULT uuidv7(),    -- 18+
    -- DEFAULT gen_random_uuid() cho version < 18
    email TEXT UNIQUE
);

-- INET / CIDR
CREATE TABLE logs (
    ip INET,
    subnet CIDR
);

-- Range type
CREATE TABLE reservations (
    id BIGSERIAL PRIMARY KEY,
    room_id INT,
    during TSRANGE NOT NULL,
    EXCLUDE USING GIST (room_id WITH =, during WITH &&)  -- chống overlap!
);
Postgres feature hot — EXCLUDE constraint: ngăn 2 row có range overlap cùng room_id. Không hệ DB nào khác có ngon thế này.

JSON vs JSONB

JSONJSONB
LưuText chuẩnNhị phân nén
InsertNhanhChậm hơn chút (parse + encode)
Query/IndexChậmNhanh hơn nhiều, hỗ trợ GIN index
Giữ whitespace, key order
Khuyến nghịAudit log rawMặc định nên dùng

2. DDL — Data Definition

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    age INT CHECK (age BETWEEN 0 AND 150),
    status TEXT NOT NULL DEFAULT 'active',
    country_id INT REFERENCES countries(id) ON DELETE SET NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Add column
ALTER TABLE users ADD COLUMN avatar_url TEXT;

-- Drop column
ALTER TABLE users DROP COLUMN avatar_url;

-- Rename
ALTER TABLE users RENAME COLUMN status TO state;

-- Change type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;

-- Drop table
DROP TABLE IF EXISTS users CASCADE;

Generated columns

-- VIRTUAL (Postgres 18 default) — tính khi đọc
ALTER TABLE products
    ADD COLUMN price_with_tax NUMERIC(18, 2)
    GENERATED ALWAYS AS (price * 1.1) VIRTUAL;

-- STORED — lưu vật lý, index được
ALTER TABLE products
    ADD COLUMN price_with_tax_stored NUMERIC(18, 2)
    GENERATED ALWAYS AS (price * 1.1) STORED;
Postgres 18 changes: generated columns mặc định là VIRTUAL (tính khi đọc, không tốn storage). Trước đó luôn STORED.

3. DML

INSERT + RETURNING (cực kỳ tiện)

INSERT INTO users (email, age)
VALUES ('alice@x.com', 25)
RETURNING id, email, created_at;

Postgres 18 thêm OLDNEW trong RETURNING:

-- 18+ — UPDATE log diff
UPDATE users
SET age = age + 1
WHERE id = 1
RETURNING OLD.age AS old_age, NEW.age AS new_age;
RETURNING là tính năng cực mạnh — viết audit log, get ID vừa insert, biết row nào bị UPDATE/DELETE. Tương đương OUTPUT của SQL Server.

Multi-row INSERT

INSERT INTO products (name, price) VALUES
('Laptop', 32000000),
('Mouse', 800000),
('Keyboard', 2200000);

UPSERT — ON CONFLICT

-- INSERT, nếu trùng email thì UPDATE
INSERT INTO users (email, age)
VALUES ('alice@x.com', 26)
ON CONFLICT (email) DO UPDATE
    SET age = EXCLUDED.age,
        updated_at = NOW();

-- Hoặc DO NOTHING
INSERT INTO users (email, age)
VALUES ('alice@x.com', 26)
ON CONFLICT DO NOTHING;
Câu PV:"Postgres UPSERT làm sao?"INSERT ... ON CONFLICT (key) DO UPDATE. Đáp án này luôn được điểm.EXCLUDED = row đang định INSERT (giúp UPDATE bằng giá trị mới).
Mới trong PostgreSQL 19:ON CONFLICT DO SELECT — hành động thứ ba cho phép atomic get-or-create không dead tuple, nhanh hơn ~4x so với no-op UPDATE. Chi tiết tại mục 8.1.

UPDATE

UPDATE products
SET price = price * 1.1, updated_at = NOW()
WHERE category_id = 1;

-- UPDATE từ JOIN (Postgres dùng FROM)
UPDATE products p
SET category_name = c.name
FROM categories c
WHERE c.id = p.category_id;

DELETE

DELETE FROM products WHERE is_active = false;

-- DELETE từ JOIN (USING)
DELETE FROM orders o
USING users u
WHERE u.id = o.user_id AND u.banned = true;

TRUNCATE

TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;            -- reset sequence
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;    -- + truncate bảng FK reference

4. NULL handling

-- IS NULL / IS NOT NULL
SELECT * FROM users WHERE email IS NULL;

-- COALESCE — first non-null
SELECT name, COALESCE(phone, email, 'no-contact') FROM users;

-- NULLIF — null nếu 2 giá trị bằng
SELECT amount / NULLIF(divisor, 0) FROM calc;

5. Function thường dùng

String

SELECT
    LENGTH('Hello'),                       -- 5
    LOWER('ABC'), UPPER('abc'),
    SUBSTRING('Hello' FROM 2 FOR 3),       -- 'ell'
    LEFT('Hello', 3), RIGHT('Hello', 2),
    TRIM('  abc  '),
    REPLACE('abc', 'b', 'X'),
    CONCAT('Hello', ' ', 'World'),
    'Hello' || ' ' || 'World',             -- || operator
    STRING_AGG(name, ', ' ORDER BY name);  -- aggregate

Date

SELECT
    NOW(),                                  -- TIMESTAMPTZ
    CURRENT_DATE, CURRENT_TIME,
    AGE(TIMESTAMP '1990-01-01'),            -- interval
    NOW() + INTERVAL '7 days',
    NOW() - INTERVAL '1 month',
    EXTRACT(YEAR FROM NOW()),
    DATE_TRUNC('month', NOW()),             -- truncate đến month
    TO_CHAR(NOW(), 'YYYY-MM-DD HH24:MI');

JSON / JSONB

-- Access
SELECT
    payload->'user' AS user_json,             -- trả JSONB
    payload->>'user' AS user_text,            -- trả TEXT
    payload->'tags'->0 AS first_tag,          -- index array
    payload->'tags'->>0 AS first_tag_text;

-- Existence
SELECT * FROM events WHERE payload ? 'type';                  -- key tồn tại
SELECT * FROM events WHERE payload ?| ARRAY['type', 'name'];  -- bất kỳ key
SELECT * FROM events WHERE payload ?& ARRAY['type', 'name'];  -- tất cả key

-- Containment (rất hay dùng + index GIN tốt)
SELECT * FROM events WHERE payload @> '{"type": "click"}';

-- Modify (18+ có thêm nhiều function)
UPDATE events
SET payload = jsonb_set(payload, '{processed}', 'true')
WHERE id = 1;

-- Path
SELECT jsonb_path_query(payload, '$.tags[*]') FROM events;
Index GIN trên JSONB — siêu mạnh:
CREATE INDEX idx_events_payload ON events USING GIN (payload);
-- Hoặc cho jsonb_path_ops nếu chỉ dùng @> operator (nhỏ hơn, nhanh hơn)
CREATE INDEX idx_events_payload ON events USING GIN (payload jsonb_path_ops);

Array

SELECT
    ARRAY[1, 2, 3, 4],
    ARRAY_LENGTH(ARRAY[1, 2, 3], 1),       -- 3
    ARRAY[1, 2] || ARRAY[3, 4],            -- {1,2,3,4}
    1 = ANY(ARRAY[1, 2, 3]),               -- TRUE
    ARRAY_AGG(name ORDER BY name);

-- Array trong WHERE
SELECT * FROM posts WHERE tags && ARRAY['vue', 'react'];   -- overlap
SELECT * FROM posts WHERE tags @> ARRAY['vue'];            -- contains

6. Variable & block (PL/pgSQL)

Postgres dùng PL/pgSQL thay vì T-SQL. Cú pháp khác hẳn:

DO $$
DECLARE
    user_count INT;
BEGIN
    SELECT COUNT(*) INTO user_count FROM users;
    RAISE NOTICE 'Total users: %', user_count;
END $$;

$$ ... $$dollar quoting — string không cần escape, có thể chứa '.

7. Function & Stored Procedure

Function (return value)

CREATE OR REPLACE FUNCTION full_name(first TEXT, last TEXT)
RETURNS TEXT
LANGUAGE SQL IMMUTABLE
AS $$
    SELECT first || ' ' || last;
$$;

SELECT full_name('Alice', 'Nguyen');

PL/pgSQL function với logic

CREATE OR REPLACE FUNCTION get_user_tier(uid BIGINT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
    spent NUMERIC;
BEGIN
    SELECT COALESCE(SUM(total), 0) INTO spent
    FROM orders WHERE user_id = uid;
    
    IF spent >= 100000000 THEN RETURN 'gold';
    ELSIF spent >= 10000000 THEN RETURN 'silver';
    ELSE RETURN 'bronze';
    END IF;
END $$;

Procedure (11+, có thể COMMIT trong thân hàm)

CREATE OR REPLACE PROCEDURE transfer(from_id BIGINT, to_id BIGINT, amount NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE accounts SET balance = balance - amount WHERE id = from_id;
    UPDATE accounts SET balance = balance + amount WHERE id = to_id;
    COMMIT;
END $$;

CALL transfer(1, 2, 1000000);
Function vs Procedure trong Postgres:
  • Function: return value, không COMMIT/ROLLBACK trong thân.
  • Procedure (Postgres 11+): không return value, có thể COMMIT/ROLLBACK trong thân, gọi bằng CALL.

8. Cải tiến DML trong PostgreSQL 19

PostgreSQL 19 (ra mắt 2026) mang đến ba cải tiến DML quan trọng giúp code ngắn hơn, nhanh hơn và chuẩn SQL hơn. Nếu đã quen thuộc với PG 16-18, đây là những tính năng bạn phải biết cho năm 2026.

8.1 ON CONFLICT DO SELECT — Atomic get-or-create

Đây là hành động thứ ba sau DO NOTHINGDO UPDATE, giúp thực hiện atomic get-or-create chỉ trong một câu lệnh duy nhất — không race condition, không dead tuple.

-- PG 19 — lấy row nếu tồn tại, insert nếu chưa, luôn trả về kết quả
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO SELECT
RETURNING *;

So sánh các pattern get-or-create:

CáchƯu điểmNhược điểm
DO NOTHING + SELECT riêngĐơn giảnCần 2 câu lệnh, 2 round-trip
DO UPDATE SET col = EXCLUDED.col (no-op update)1 câu lệnhTạo dead tuple, bloat, chậm vacuum
DO SELECT (PG 19)1 câu lệnh, atomic, không dead tupleCần PG 19+
-- Pattern cũ (PG 18 trở về trước) — vẫn chạy nhưng tạo dead tuple không cần thiết
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email  -- dirty no-op
RETURNING *;

-- PG 19 — sạch hơn, nhanh hơn ~4x
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO SELECT
RETURNING *;
Câu PV 2026:"ON CONFLICT DO SELECT khác gì DO NOTHING?"

"DO NOTHING chỉ insert nếu không conflict — nếu conflict thì không return gì cả, bạn buộc phải SELECT lại để lấy row đã tồn tại. DO SELECT trả về luôn row bị conflict, giúp bạn có kết quả sau đúng 1 câu lệnh. Đây là pattern atomic get-or-create chuẩn chỉ: không race condition (không ai xen giữa SELECT và INSERT được), không dead tuple (không phải UPDATE vô nghĩa), và nhanh hơn ~4x so với cách no-op update cũ."

Khi nào dùng gì:
  • DO NOTHING — khi bạn không cần row trả về (VD: log, dedup silent)
  • DO UPDATE SET — khi bạn thực sự muốn update dữ liệu (VD: counter, merge)
  • DO SELECT — khi bạn muốn lấy row về mà không cần update gì (VD: ensure tồn tại rồi đọc)

8.2 GROUP BY ALL — Nhóm theo mọi cột non-aggregate

Có trong: PostgreSQL 19
-- PG 19 — tự động GROUP BY mọi non-aggregate column trong SELECT
SELECT department, role, count(*)
FROM employees
GROUP BY ALL;

Không cần liệt kê từng cột trong GROUP BY nữa. Khi thêm hoặc xóa cột trong SELECT, GROUP BY ALL tự cập nhật — không còn lỗi quên sửa GROUP BY.

-- Thêm cột location mà không cần sửa GROUP BY
SELECT department, role, location, count(*)
FROM employees
GROUP BY ALL;  -- tự hiểu là GROUP BY department, role, location

-- Xóa cột cũng vậy — GROUP BY ALL tự điều chỉnh
SELECT department, count(*)
FROM employees
GROUP BY ALL;  -- tự hiểu là GROUP BY department
Câu PV:"GROUP BY ALL có vấn đề gì về hiệu năng không?"

"Không. GROUP BY ALL được planner expand ra danh sách cột tường minh tại parse time, kế hoạch thực thi giống hệt như viết tay. Nó là syntactic sugar — tiện lợi mà không phải trả giá. Lưu ý duy nhất: nếu bạn có column trong SELECT là aggregate expression lồng ghép, hãy kiểm tra lại xem planner có hiểu đúng ý bạn không."

8.3 FOR PORTION OF — Cập nhật dữ liệu temporal

Có trong: PostgreSQL 19 — hoàn thiện bộ tính năng temporal SQL:2011

Khi làm việc với dữ liệu lịch sử (slowly changing dimension, bảng giá theo thời gian, lương nhân viên qua các kỳ), FOR PORTION OF tự động chia row để chỉ cập nhật đúng khoảng thời gian cần thiết:

-- Bảng giá sản phẩm theo thời gian (temporal table)
CREATE TABLE product_prices (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_id INT NOT NULL,
    price NUMERIC(18, 2) NOT NULL,
    valid_range TSRANGE NOT NULL,
    EXCLUDE USING GIST (product_id WITH =, valid_range WITH &&)
);

-- Dữ liệu ban đầu: giá 29.99 áp dụng cả năm 2025
INSERT INTO product_prices (product_id, price, valid_range)
VALUES (1, 29.99, '[2025-01-01, 2025-12-31)');

-- PG 19 — cập nhật giá cho Q3/2025, Postgres tự chia row
UPDATE product_prices
FOR PORTION OF valid_range FROM '2025-07-01' TO '2025-10-01'
SET price = 34.99
WHERE product_id = 1;

Postgres tự động làm gì? Row gốc valid_range = [2025-01-01, 2025-12-31) được tự động chia thành 3 row:

  • [2025-01-01, 2025-07-01) — giá cũ 29.99
  • [2025-07-01, 2025-10-01) — giá mới 34.99
  • [2025-10-01, 2025-12-31) — giá cũ 29.99

Không cần viết 3-4 câu INSERT/DELETE/UPDATE thủ công như trước PG 19.

Câu PV:"FOR PORTION OF dùng khi nào? So sánh với cách thủ công?"

"Dùng khi bạn có dữ liệu phiên bản theo thời gian (temporal/bitemporal tables) — bảng giá sản phẩm, lương nhân viên, trạng thái hợp đồng. Trước PG 19, để update một khoảng thời gian bạn phải: (1) SELECT row cũ, (2) UPDATE row cũ thu hẹp range, (3) INSERT row mới cho khoảng update, (4) INSERT row mới cho phần còn lại. FOR PORTION OF làm tất cả trong 1 câu — tránh race condition và code đơn giản hơn nhiều. Đây là chuẩn SQL:2011 temporal — trước PG 19 chỉ có DB2 và Oracle hỗ trợ đầy đủ."

8.4 IGNORE NULLS cho Window Functions

Có trong: PostgreSQL 19

Các window function lead(), lag(), first_value(), last_value(), nth_value() giờ hỗ trợ IGNORE NULLS để bỏ qua giá trị NULL khi duyệt qua partition:

-- PG 19 — forward-fill: điền giá trị sensor gần nhất không NULL
SELECT
    sensor_id,
    ts,
    reading,
    last_value(reading) IGNORE NULLS OVER (
        PARTITION BY sensor_id ORDER BY ts
    ) AS last_known_reading
FROM sensor_data;

So sánh hành vi:

RESPECT NULLS (mặc định)IGNORE NULLS
Gặp NULLTrả về NULLBỏ qua, lấy giá trị non-NULL gần nhất
Use caseGiữ nguyên dữ liệu gốcForward-fill, backward-fill, gap-filling
Mặc định là RESPECT NULLS (giữ nguyên NULL — tương đương hành vi cũ). Chi tiết về window functions xem tại bài Truy vấn nâng cao.

© 2026 .NET Fresher Guide. All rights reserved.

Về Trang Web

Hướng dẫn toàn diện để chuẩn bị phỏng vấn vị trí .NET Fresher với nội dung từ lý thuyết đến thực hành.