Tìm hiểu 6 loại JOIN trong SQL mà mọi nhà phân tích nên biết — INNER, LEFT, RIGHT, FULL OUTER, CROSS và SELF — với ví dụ thực tế và lỗi thường gặp.

Một SQL JOIN cho phép bạn kết hợp các hàng từ hai (hoặc nhiều) bảng thành một kết quả bằng cách khớp chúng theo một cột liên quan — thường là một ID.
Hầu hết cơ sở dữ liệu thực tế được tách thành nhiều bảng để tránh lặp lại thông tin. Ví dụ, tên khách hàng nằm trong bảng customers, trong khi đơn hàng của họ nằm trong bảng orders. JOIN là cách bạn nối lại những phần đó khi cần câu trả lời.
Đó là lý do JOIN xuất hiện khắp nơi trong báo cáo và phân tích:
Không có JOIN, bạn sẽ phải chạy nhiều truy vấn riêng biệt và ghép kết quả bằng tay — chậm, dễ sai và khó lặp lại.
Nếu bạn xây sản phẩm trên cơ sở dữ liệu quan hệ (dashboard, admin panel, công cụ nội bộ, portal khách hàng), JOIN cũng là thứ biến “bảng thô” thành các view cho người dùng. Những nền tảng như Koder.ai (tự động tạo ứng dụng React + Go + PostgreSQL từ chat) vẫn dựa vào các nguyên tắc JOIN vững chắc khi bạn cần các trang danh sách chính xác, báo cáo và màn đối chiếu — vì logic cơ sở dữ liệu không biến mất, ngay cả khi phát triển nhanh hơn.
Hướng dẫn này tập trung vào sáu JOIN bao quát phần lớn công việc SQL hàng ngày:
Cú pháp JOIN rất giống nhau ở hầu hết cơ sở dữ liệu (PostgreSQL, MySQL, SQL Server, SQLite). Có vài khác biệt — đặc biệt quanh hỗ trợ FULL OUTER JOIN và một số hành vi cạnh — nhưng khái niệm và mẫu cơ bản chuyển giao tốt.
Để giữ ví dụ JOIN đơn giản, chúng ta sẽ dùng ba bảng nhỏ phản ánh setup thực tế: customers đặt orders, và orders có thể (hoặc không) có payments.
Một lưu ý nhỏ trước khi bắt đầu: các bảng mẫu dưới đây chỉ hiển thị vài cột, nhưng một số truy vấn sau dùng thêm trường khác (như order_date, created_at, status, hoặc paid_at) để minh họa các mẫu phổ biến. Xem những cột đó như các trường “điển hình” bạn thường có trong schema production.
Primary key: customer_id
| customer_id | name |
|---|---|
| 1 | Ava |
| 2 | Ben |
| 3 | Chen |
| 4 | Dia |
Primary key: order_id
Foreign key: customer_id → customers.customer_id
| order_id | customer_id | order_total |
|---|---|---|
| 101 | 1 | 50 |
| 102 | 1 | 120 |
| 103 | 2 | 35 |
| 104 | 5 | 70 |
Lưu ý order_id = 104 tham chiếu customer_id = 5, không tồn tại trong customers. Sự “khớp thiếu” này hữu ích để thấy cách LEFT JOIN, RIGHT JOIN và FULL OUTER JOIN hành xử.
Primary key: payment_id
Foreign key: order_id → orders.order_id
| payment_id | order_id | amount |
|---|---|---|
| 9001 | 101 | 50 |
| 9002 | 102 | 60 |
| 9003 | 102 | 60 |
| 9004 | 999 | 25 |
Hai chi tiết quan trọng để dạy:
order_id = 102 có hai hàng payment (chia thanh toán). Khi bạn join orders với payments, order đó sẽ xuất hiện hai lần — đây là nơi các bản sao thường làm mọi người bất ngờ.payment_id = 9004 tham chiếu order_id = 999, không tồn tại trong orders. Đó lại là một trường hợp “không khớp”.orders với payments sẽ nhân bản order 102 vì có hai payment liên quan.INNER JOIN trả về chỉ những hàng có khớp ở cả hai bảng. Nếu một khách hàng không có đơn, họ sẽ không xuất hiện. Nếu một đơn tham chiếu khách hàng không tồn tại (dữ liệu xấu), đơn đó cũng sẽ không xuất hiện.
Bạn chọn bảng “trái”, join bảng “phải”, và nối bằng điều kiện trong ON.
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_date
FROM customers c
INNER JOIN orders o
ON o.customer_id = c.customer_id;
Ý chính là dòng ON o.customer_id = c.customer_id: nó nói cho SQL biết các hàng liên quan như thế nào.
Nếu bạn muốn danh sách chỉ những khách hàng đã thực sự đặt ít nhất một đơn (và chi tiết đơn), INNER JOIN là lựa chọn tự nhiên:
SELECT
c.name,
o.order_id,
o.total_amount
FROM customers c
INNER JOIN orders o
ON o.customer_id = c.customer_id
ORDER BY o.order_id;
Điều này hữu dụng cho việc như “gửi email theo dõi đơn” hoặc “tính doanh thu theo khách hàng” (khi bạn chỉ quan tâm khách hàng có mua hàng).
Nếu bạn viết join mà quên ON (hoặc join sai cột), bạn có thể vô tình tạo tích Descartes (mọi customer kết hợp với mọi order) hoặc tạo khớp sai.
Xấu (đừng làm):
SELECT c.name, o.order_id
FROM customers c
JOIN orders o;
Luôn đảm bảo có điều kiện join rõ ràng trong ON (hoặc USING khi phù hợp).
LEFT JOIN trả về tất cả hàng từ bảng trái, và thêm dữ liệu khớp từ bảng phải khi có. Nếu không có khớp, các cột bên phải sẽ là NULL.
Dùng LEFT JOIN khi bạn muốn danh sách đầy đủ từ bảng chính, cộng dữ liệu liên quan nếu có.
Ví dụ: “Hiển thị tất cả khách hàng, và bao gồm đơn hàng của họ nếu có.”
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id;
o.order_id (và các cột orders) sẽ là NULL.Một lý do rất phổ biến để dùng LEFT JOIN là tìm những mục không có bản ghi liên quan.
Ví dụ: “Khách hàng nào chưa bao giờ đặt hàng?”
SELECT
c.customer_id,
c.name
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
Câu điều kiện WHERE ... IS NULL giữ chỉ những hàng bên trái mà join không tìm được khớp.
LEFT JOIN có thể “nhân” hàng bên trái khi có nhiều hàng khớp ở bên phải.
Nếu một khách hàng có 3 đơn, khách hàng đó sẽ xuất hiện 3 lần — một lần cho mỗi đơn. Điều này đúng, nhưng có thể làm bạn ngạc nhiên khi cố đếm số khách hàng.
Ví dụ, truy vấn sau đếm đơn (không phải khách hàng):
SELECT COUNT(*)
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id;
Nếu mục tiêu là đếm khách hàng, bạn thường sẽ đếm khóa khách hàng thay vào đó (thường với COUNT(DISTINCT c.customer_id)), tùy vào cách bạn đo.
RIGHT JOIN giữ mọi hàng từ bảng phải, và chỉ các hàng khớp từ bảng trái. Nếu không khớp, các cột bảng trái là NULL. Về bản chất nó là ảnh phản chiếu của LEFT JOIN.
Dùng bảng ví dụ, giả sử bạn muốn liệt kê mọi payment, ngay cả khi không thể liên kết với order (có thể order đã bị xóa hoặc dữ liệu payment lộn xộn).
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount,
p.paid_at
FROM orders o
RIGHT JOIN payments p
ON o.order_id = p.order_id;
Bạn sẽ nhận được:
payments ở bên phải).o.order_id và o.customer_id sẽ là NULL.Hầu hết thời gian, bạn có thể viết lại RIGHT JOIN thành LEFT JOIN bằng cách đổi thứ tự bảng:
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount,
p.paid_at
FROM payments p
LEFT JOIN orders o
ON o.order_id = p.order_id;
Kết quả giống nhau, nhưng nhiều người thấy dễ đọc hơn vì bắt đầu với bảng “chính” họ quan tâm (ở đây là payments) rồi kéo dữ liệu liên quan là tùy chọn.
Nhiều style guide SQL khuyên tránh RIGHT JOIN vì buộc người đọc phải đảo ngược mô hình thông thường:
Khi các quan hệ tùy chọn luôn viết thành LEFT JOIN, truy vấn dễ quét hơn.
RIGHT JOIN hữu ích khi bạn chỉnh sửa truy vấn có sẵn và nhận ra bảng “phải giữ” đang ở bên phải. Thay vì viết lại toàn bộ truy vấn (đặc biệt khi nó dài và có nhiều join), đổi một join thành RIGHT JOIN có thể là thay đổi nhanh và ít rủi ro.
FULL OUTER JOIN trả về mọi hàng từ cả hai bảng.
INNER JOIN).NULL cho cột bên phải.NULL cho cột bên trái.Một trường hợp kinh điển là đối chiếu orders vs. payments:
Ví dụ:
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount
FROM orders o
FULL OUTER JOIN payments p
ON p.order_id = o.order_id;
FULL OUTER JOIN được hỗ trợ trong PostgreSQL, SQL Server, và Oracle.
Nó không có trong MySQL và SQLite (bạn cần thủ thuật thay thế).
Nếu DB không hỗ trợ FULL OUTER JOIN, bạn có thể mô phỏng bằng cách kết hợp:
orders (với payments khớp khi có), vàpayments mà không khớp với orders.Một mẫu phổ biến:
SELECT o.order_id, o.customer_id, p.payment_id, p.amount
FROM orders o
LEFT JOIN payments p
ON p.order_id = o.order_id
UNION
SELECT o.order_id, o.customer_id, p.payment_id, p.amount
FROM orders o
RIGHT JOIN payments p
ON p.order_id = o.order_id;
Mẹo: khi bạn thấy NULL ở một phía, đó là tín hiệu hàng đó “thiếu” ở bảng kia — chính xác điều bạn muốn cho kiểm toán và đối chiếu.
CROSS JOIN trả về mọi cặp hàng giữa hai bảng. Nếu bảng A có 3 hàng và bảng B có 4 hàng, kết quả sẽ có 3 × 4 = 12 hàng. Đây còn gọi là tích Descartes.
Nghe có vẻ nguy hiểm — và đúng là vậy — nhưng nó thực sự hữu ích khi bạn muốn tạo các kết hợp.
Giả sử bạn quản lý các tuỳ chọn sản phẩm riêng:
sizes: S, M, Lcolors: Red, BlueMột CROSS JOIN có thể sinh tất cả biến thể:
SELECT
s.size,
c.color
FROM sizes AS s
CROSS JOIN colors AS c;
Kết quả (3 × 2 = 6 hàng):
Vì số hàng nhân lên, CROSS JOIN có thể bùng nổ nhanh:
Điều này có thể làm chậm truy vấn, quá tải bộ nhớ, và tạo ra đầu ra không sử dụng được. Nếu cần kết hợp, giữ các bảng đầu vào nhỏ và cân nhắc thêm giới hạn hoặc bộ lọc.
SELF JOIN là join một bảng với chính nó. Điều này hữu ích khi một hàng trong bảng liên quan tới một hàng khác trong cùng bảng — thường là quan hệ cha/con như nhân viên và quản lý.
Vì dùng cùng một bảng hai lần, bạn phải đặt bí danh khác nhau cho mỗi “bản sao”. Bí danh làm truy vấn dễ đọc và cho SQL biết bạn đang tham chiếu bên nào.
Mẫu phổ biến:
e cho employeem cho managerGiả sử bảng employees có:
idnamemanager_id (trỏ tới id của nhân viên khác)Để liệt kê mỗi nhân viên cùng tên quản lý:
SELECT
e.id,
e.name AS employee_name,
m.name AS manager_name
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id;
Lưu ý truy vấn dùng LEFT JOIN, không phải INNER JOIN. Điều này quan trọng vì một số nhân viên có thể không có quản lý (ví dụ CEO). Trong trường hợp đó, manager_id thường là NULL, và LEFT JOIN giữ hàng nhân viên trong khi hiển thị manager_name là NULL.
Nếu dùng INNER JOIN thay vì, những nhân viên cấp cao đó sẽ biến mất khỏi kết quả vì không có hàng quản lý để join.
Một JOIN không “tự biết” hai bảng liên quan thế nào — bạn phải chỉ định. Mối quan hệ đó được định nghĩa trong điều kiện JOIN, và nó nên đứng ngay cạnh từ khóa JOIN vì nó giải thích cách các bảng khớp, không phải cách bạn lọc kết quả cuối.
ON: linh hoạt nhất (và phổ biến nhất)Dùng ON khi bạn muốn toàn quyền kiểm soát logic khớp — tên cột khác nhau, nhiều điều kiện, hoặc quy tắc thêm.
SELECT
c.customer_id,
c.name,
o.order_id,
o.created_at
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id;
ON cũng là nơi bạn định nghĩa những khớp phức tạp hơn (ví dụ khớp trên hai cột) mà không làm truy vấn khó hiểu.
USING: ngắn gọn nhưng chỉ cho cột cùng tênMột số DB (như PostgreSQL và MySQL) hỗ trợ USING. Nó là viết tắt tiện khi cả hai bảng có một cột cùng tên và bạn muốn join theo cột đó.
SELECT
customer_id,
name,
order_id
FROM customers
JOIN orders
USING (customer_id);
Một lợi ích: USING thường trả về chỉ một cột customer_id trong output (thay vì hai cột giống nhau).
Sau khi join, tên cột thường trùng nhau (id, created_at, status). Nếu bạn viết SELECT id, DB có thể báo lỗi “ambiguous column” — hoặc tệ hơn, bạn có thể đọc nhầm id.
Ưu tiên tiền tố bảng (hoặc bí danh) cho rõ ràng:
SELECT c.customer_id, o.order_id
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id;
SELECT * trong truy vấn có joinSELECT * nhanh chóng trở nên lộn xộn với joins: bạn kéo vào các cột không cần, gặp rủi ro trùng tên, và làm khó hiểu mục đích truy vấn.
Thay vào đó, chọn đúng cột bạn cần. Kết quả rõ ràng hơn, dễ bảo trì hơn, và thường hiệu quả hơn — đặc biệt khi bảng rộng.
Khi bạn join bảng, WHERE và ON đều “lọc”, nhưng ở những thời điểm khác nhau.
Sự khác về thời gian này là lý do nhiều người vô tình biến LEFT JOIN thành INNER JOIN.
Giả sử bạn muốn tất cả khách hàng, kể cả những người không có đơn trả tiền gần đây.
SELECT c.customer_id, c.name, o.order_id, o.status, o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'PAID'
AND o.order_date >= DATE '2025-01-01';
Vấn đề: với khách hàng không có đơn, o.status và o.order_date là NULL. Điều kiện WHERE loại những hàng đó, nên khách hàng không khớp biến mất — LEFT JOIN hoạt động như INNER JOIN.
SELECT c.customer_id, c.name, o.order_id, o.status, o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'PAID'
AND o.order_date >= DATE '2025-01-01';
Bây giờ khách hàng không có đơn phù hợp vẫn hiển thị (với các cột order là NULL), thường là mục đích khi dùng LEFT JOIN.
WHERE o.order_id IS NOT NULL rõ ràng).Joins không chỉ “thêm cột” — chúng còn có thể nhân hàng. Điều này thường đúng nhưng thường làm mọi người ngạc nhiên khi tổng đột nhiên tăng gấp đôi (hoặc tệ hơn).
Một join trả về một hàng kết quả cho mỗi cặp hàng khớp.
customers với orders, mỗi khách hàng có thể xuất hiện nhiều lần — một lần cho mỗi đơn.orders với payments và mỗi order có thể có nhiều payment, bạn có thể có nhiều hàng cho mỗi order. Nếu bạn cũng join đến một bảng “many” khác (như order_items), bạn có thể tạo hiệu ứng nhân: payments × items cho mỗi order.Nếu mục tiêu là “một hàng cho mỗi khách hàng” hoặc “một hàng cho mỗi order”, hãy tóm tắt phía “many” trước, rồi mới join.
-- Một hàng cho mỗi order từ payments
WITH payment_totals AS (
SELECT
order_id,
SUM(amount) AS total_paid,
COUNT(*) AS payment_count
FROM payments
GROUP BY order_id
)
SELECT
o.order_id,
o.customer_id,
COALESCE(pt.total_paid, 0) AS total_paid,
COALESCE(pt.payment_count, 0) AS payment_count
FROM orders o
LEFT JOIN payment_totals pt
ON pt.order_id = o.order_id;
Điều này giữ hình dạng join dự đoán được: một hàng order vẫn là một hàng order.
SELECT DISTINCT có thể làm vẻ như loại bỏ trùng lặp, nhưng nó có thể che dấu vấn đề thực sự:
Chỉ dùng khi bạn chắc chắn các bản sao là vô ý và hiểu vì sao chúng xảy ra.
Trước khi tin tưởng kết quả, so sánh số lượng hàng:
JOINs thường bị đổ lỗi cho “truy vấn chậm”, nhưng nguyên nhân thực sự thường là bạn yêu cầu DB kết hợp bao nhiêu dữ liệu, và DB có thể tìm các hàng khớp nhanh thế nào.
Hãy tưởng tượng index như mục lục của một cuốn sách. Không có index, DB có thể phải quét nhiều hàng để tìm khớp cho điều kiện JOIN của bạn. Có index trên khóa join (ví dụ customers.customer_id và orders.customer_id) thì DB có thể nhảy tới hàng liên quan nhanh hơn.
Bạn không cần hiểu chi tiết nội bộ để dùng tốt: nếu một cột thường dùng để khớp hàng (ON a.id = b.a_id), đó là ứng viên tốt để được index.
Khi có thể, join trên các định danh ổn định, duy nhất:
customers.customer_id = orders.customer_idcustomers.email = orders.email hoặc customers.name = orders.nameTên có thể thay đổi và trùng nhau. Email có thể thay đổi, thiếu hoặc khác kiểu chữ. ID được thiết kế để khớp nhất quán và thường đã được index.
Hai thói quen làm JOIN nhanh hơn rõ rệt:
SELECT * khi join nhiều bảng — cột thừa tăng dùng bộ nhớ và băng thông.Ví dụ: giới hạn orders trước, rồi join:
SELECT c.customer_id, c.name, o.order_id, o.created_at
FROM customers c
JOIN (
SELECT order_id, customer_id, created_at
FROM orders
WHERE created_at >= DATE '2025-01-01'
) o
ON o.customer_id = c.customer_id;
Nếu bạn lặp trên các truy vấn này trong khi xây app (ví dụ tạo trang báo cáo dùng PostgreSQL), các công cụ như Koder.ai có thể tăng tốc phần scaffold — schema, endpoints, UI — trong khi bạn vẫn kiểm soát logic JOIN quyết định độ chính xác.
NULL)NULL khi thiếu)NULLMột JOIN trong SQL kết hợp các hàng từ hai (hoặc nhiều) bảng thành một tập kết quả bằng cách khớp các cột có liên quan — thường là một khóa chính với khóa ngoại (ví dụ: customers.customer_id = orders.customer_id). Đây là cách bạn “nối lại” các bảng đã chuẩn hóa khi cần báo cáo, đối chiếu hoặc phân tích.
Dùng INNER JOIN khi bạn chỉ muốn các hàng có mối quan hệ tồn tại ở cả hai bảng.
Nó lý tưởng cho những mối quan hệ “đã xác nhận”, như liệt kê chỉ những khách hàng thực sự đã đặt hàng.
Dùng LEFT JOIN khi bạn cần tất cả các hàng từ bảng chính (bên trái), cộng thêm dữ liệu khớp từ bên phải nếu có.
Để tìm “không có khớp”, JOIN rồi lọc phần bên phải là NULL:
SELECT c.customer_id, c.name
customers c
orders o o.customer_id c.customer_id
o.order_id ;
RIGHT JOIN giữ mọi hàng từ bảng bên phải và điền NULL cho các cột bên trái khi không có khớp. Nhiều đội tránh dùng nó vì đọc “ngược”.
Trong hầu hết trường hợp, bạn có thể viết lại bằng LEFT JOIN bằng cách đổi thứ tự bảng:
FROM payments p
LEFT JOIN orders o o.order_id p.order_id
Dùng FULL OUTER JOIN để đối chiếu: bạn muốn xuất cả các bản khớp, các hàng chỉ bên trái và các hàng chỉ bên phải trong cùng một kết quả.
Nó rất hữu ích cho kiểm toán như “orders không có payments” và “payments không có orders”, vì các bên không khớp sẽ hiện dưới dạng cột NULL.
Một số cơ sở dữ liệu (ví dụ MySQL và SQLite) không hỗ trợ FULL OUTER JOIN trực tiếp. Cách làm phổ biến là kết hợp hai truy vấn:
orders LEFT JOIN paymentsThường thực hiện bằng UNION (hoặc UNION ALL kèm lọc cẩn thận) để giữ cả các bản “left-only” và “right-only”.
CROSS JOIN trả về mọi kết hợp giữa các hàng của hai bảng (tích Descartes). Nó hữu ích để tạo kịch bản (ví dụ sizes × colors) hoặc xây lưới lịch.
Cẩn thận: số hàng nhân lên rất nhanh, nên đảm bảo các bảng đầu vào nhỏ và được kiểm soát.
Self join là việc JOIN một bảng với chính nó để liên kết các hàng trong cùng một bảng (thường cho cấu trúc phân cấp như employee → manager).
Bạn phải dùng bí danh để phân biệt hai “bản sao”:
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id
ON định nghĩa cách các hàng khớp trong lúc JOIN; WHERE lọc sau khi kết quả JOIN đã được hình thành. Với LEFT JOIN, điều kiện WHERE trên bảng bên phải có thể vô tình loại bỏ các hàng NULL và biến nó thành INNER JOIN.
Nếu bạn muốn giữ tất cả hàng bên trái nhưng giới hạn các hàng bên phải được phép khớp, đặt điều kiện liên quan đến bảng bên phải trong thay vì .
Joins nhân hàng khi mối quan hệ là một-nhiều hoặc nhiều-nhiều. Ví dụ, một order có hai payment sẽ xuất hiện hai lần khi JOIN orders với payments.
Để tránh đếm nhầm, hãy tổng hợp phía “many” trước (ví dụ SUM(amount) nhóm theo order_id) rồi mới JOIN. Dùng DISTINCT chỉ khi thực sự cần và bạn hiểu tại sao các bản sao xảy ra, vì có thể che dấu vấn đề thực sự và làm sai tổng cộng.
ONWHEREDISTINCT