SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 11 phút đọc

Hàm chuỗi trong SQL: CONCAT, SUBSTRING, REPLACE, UPPER, TRIM và hơn thế

Những thẻ hành lý bằng giấy ghi tên hành khách đang được cắt, ghép và dán nhãn lại trên bàn phân loại

Hàm chuỗi giúp bạn làm sạch và biến đổi văn bản ngay trong truy vấn, thay vì xuất ra bảng tính rồi sửa tay. Chỉ một nhóm nhỏ hàm đã đủ cho phần lớn công việc thực tế: || và CONCAT nối văn bản, SUBSTRING, LEFT, RIGHT và SPLIT_PART cắt ra từng phần, REPLACE và REGEXP_REPLACE thay ký tự, UPPER, LOWER và TRIM chuẩn hóa giá trị để chúng khớp nhau, còn LENGTH và POSITION đo độ dài và tìm kiếm. Bài này đi qua từng hàm trên dữ liệu thật và chỉ ra những chỗ PostgreSQL, DuckDB và các hệ quản trị khác xử lý khác nhau.

Mọi truy vấn ở đây đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto, theo cú pháp PostgreSQL trên DuckDB (engine chạy phía sau phòng học SQL của Nomto). Các bảng kết quả là kết quả chạy thật. Những cột văn bản chúng ta sẽ dùng:

Bảng Cột Giá trị ví dụ
airports airport_code, airport_name, city SVO, Sheremetyevo, Moscow
aircrafts model Sukhoi Superjet-100
flights flight_no PG0405
seats seat_no 10A
tickets passenger_name, contact_data (JSON) VALERIY TIKHONOV, {"phone": "+70127117011"}

Nối chuỗi: || và CONCAT

Trong PostgreSQL và DuckDB, cách chuẩn để nối chuỗi là toán tử ||. Hàm CONCAT() làm cùng việc đó dưới dạng hàm:

SELECT airport_code,
       city,
       airport_name,
       city || ' (' || airport_code || ')' AS label,
       CONCAT(city, ' - ', airport_name) AS full_name
FROM airports
WHERE city IN ('Moscow', 'Sochi')
ORDER BY airport_code;
airport_code city airport_name label full_name
AER Sochi Sochi Sochi (AER) Sochi - Sochi
DME Moscow Domodedovo Moscow (DME) Moscow - Domodedovo
SVO Moscow Sheremetyevo Moscow (SVO) Moscow - Sheremetyevo
VKO Moscow Vnukovo Moscow (VKO) Moscow - Vnukovo

Hai cách này không hoàn toàn giống nhau. Chúng xử lý NULL khác nhau:

SELECT 'PG' || NULL AS pipes,
       CONCAT('PG', NULL) AS concat_fn,
       concat_ws(' / ', 'SVO', NULL, 'LED') AS ws;
pipes concat_fn ws
NULL PG SVO / LED

Với ||, chỉ một giá trị NULL cũng biến cả kết quả thành NULL. CONCAT bỏ qua các NULL. concat_ws (concatenate with separator, nối kèm dấu phân cách) đặt dấu phân cách giữa các giá trị và cũng bỏ qua NULL, rất tiện khi ghép địa chỉ hay nhãn tuyến bay từ những phần không bắt buộc. Nếu bạn tạo nhãn bằng || và một số dòng ra rỗng, nguyên nhân thường gặp là một cột NULL; hãy bọc cột đó trong COALESCE(col, '') hoặc chuyển sang CONCAT.

Các hệ quản trị khác. || và CONCAT đều chạy được trong PostgreSQL, DuckDB, Oracle và SQLite. MySQL dùng CONCAT (ở đó || nghĩa là phép OR logic, trừ khi bật một chế độ riêng), còn SQL Server dùng + hoặc CONCAT.

UPPER, LOWER và viết hoa chữ cái đầu của tên

UPPER và LOWER đổi chữ hoa, chữ thường. Tên hành khách được lưu bằng chữ in hoa:

SELECT passenger_name,
       lower(passenger_name) AS lower_name,
       upper(left(split_part(passenger_name, ' ', 1), 1))
         || lower(substring(split_part(passenger_name, ' ', 1), 2)) AS first_name,
       length(passenger_name) AS len
FROM tickets
LIMIT 3;
passenger_name lower_name first_name len
VALERIY TIKHONOV valeriy tikhonov Valeriy 16
EVGENIYA ALEKSEEVA evgeniya alekseeva Evgeniya 18
ARTUR GERASIMOV artur gerasimov Artur 15

PostgreSQL có initcap(passenger_name), viết hoa chữ cái đầu của mọi từ trong một lần (Valeriy Tikhonov). DuckDB không có initcap, nên truy vấn trên tự làm việc đó: chữ cái đầu viết hoa, phần còn lại viết thường.

Công dụng lớn hơn của LOWER là so khớp. So sánh chuỗi phân biệt hoa thường: WHERE city = 'moscow' không tìm thấy gì. Hãy so sánh lower(city) = 'moscow', hoặc dùng ILIKE, tức là LIKE không phân biệt hoa thường:

SELECT COUNT(*) FILTER (WHERE airport_name LIKE '%ovo') AS like_ovo,
       COUNT(*) FILTER (WHERE city ILIKE 'novo%')       AS ilike_novo
FROM airports;
like_ovo ilike_novo
11 2

ILIKE chạy được trong PostgreSQL và DuckDB nhưng không có trong MySQL hay SQL Server; ở đó việc phân biệt hoa thường phụ thuộc vào collation của cột.

SUBSTRING, LEFT, RIGHT và SPLIT_PART: cắt văn bản

substring(text, start, length) trả về một phần của chuỗi. Vị trí bắt đầu từ 1, và nếu bỏ qua độ dài, bạn nhận được mọi thứ đến hết chuỗi. Số hiệu chuyến bay gồm mã hãng PG cộng bốn chữ số:

SELECT flight_no,
       substring(flight_no, 3) AS num,
       CAST(substring(flight_no, 3) AS INTEGER) AS num_int,
       right(flight_no, 4) AS r
FROM flights
WHERE flight_id IN (1, 2);
flight_no num num_int r
PG0405 0405 405 0405
PG0404 0404 404 0404

num vẫn là văn bản ('0405'); hãy ép kiểu nếu bạn cần sắp xếp hay so sánh theo số, nếu không '1000' sẽ đứng trước '999'. LEFT(text, n) và RIGHT(text, n) là cách viết tắt để lấy n ký tự đầu hoặc cuối. PostgreSQL cũng chấp nhận dạng chuẩn substring(text FROM 3 FOR 4); MySQL và SQL Server cũng dùng SUBSTRING(text, 3, 4), còn Oracle gọi nó là SUBSTR.

Khi văn bản có dấu phân cách, split_part(text, delimiter, n) dễ hơn đếm vị trí. Tách tên hành khách thành tên và họ, kèm cách làm tương tự bằng POSITION:

SELECT passenger_name,
       split_part(passenger_name, ' ', 1) AS first_name,
       split_part(passenger_name, ' ', 2) AS last_name,
       position(' ' IN passenger_name) AS space_at,
       substring(passenger_name FROM 1 FOR position(' ' IN passenger_name) - 1) AS first2,
       left(passenger_name, 1) AS initial
FROM tickets
LIMIT 3;
passenger_name first_name last_name space_at first2 initial
VALERIY TIKHONOV VALERIY TIKHONOV 8 VALERIY V
EVGENIYA ALEKSEEVA EVGENIYA ALEKSEEVA 9 EVGENIYA E
ARTUR GERASIMOV ARTUR GERASIMOV 6 ARTUR A

position(' ' IN x) và strpos(x, ' ') là một trong PostgreSQL và DuckDB; cả hai trả về 0 khi không tìm thấy chuỗi con. split_part có trong PostgreSQL, DuckDB và Snowflake; MySQL dùng SUBSTRING_INDEX thay thế, còn SQL Server không có cả hai.

Khi đã tách được một phần, bạn có thể nhóm theo nó. Những tên phổ biến nhất trên vé:

SELECT split_part(passenger_name, ' ', 1) AS first_name,
       COUNT(*) AS tickets
FROM tickets
GROUP BY 1
ORDER BY 2 DESC
LIMIT 5;
first_name tickets
ALEKSANDR 20328
SERGEY 15133
VLADIMIR 12806
TATYANA 12058
ELENA 11291

REPLACE và REGEXP_REPLACE

replace(text, from, to) thay mọi lần xuất hiện của một chuỗi con:

SELECT model,
       replace(model, 'Sukhoi ', '') AS short,
       replace(model, '-', ' ') AS no_dash
FROM aircrafts
WHERE model LIKE 'S%' OR model LIKE 'Bombardier%';
model short no_dash
Bombardier CRJ-200 Bombardier CRJ-200 Bombardier CRJ 200
Sukhoi Superjet-100 Superjet-100 Sukhoi Superjet 100

Nếu chuỗi con không có trong giá trị, giá trị được trả về nguyên vẹn. Thay bằng '' nghĩa là xóa.

Khi thứ cần thay là một mẫu chứ không phải văn bản cố định, hãy dùng biểu thức chính quy (regex). Số ghế ghép từ số hàng và một chữ cái (10A); tách chúng bằng cách xóa chữ số hoặc chữ cái:

SELECT seat_no,
       regexp_replace(seat_no, '[0-9]', '', 'g') AS letter,
       CAST(regexp_replace(seat_no, '[A-Z]', '', 'g') AS INTEGER) AS row_no
FROM seats
WHERE aircraft_code = '319'
ORDER BY row_no, letter
LIMIT 4;
seat_no letter row_no
1A A 1
1C C 1
1D D 1
1F F 1

Cờ 'g' nghĩa là “global”: thay mọi kết quả khớp, không chỉ kết quả đầu tiên. Không có nó, regexp_replace('12A', '[0-9]', '') cho ra 2A. Điều này giống nhau trong PostgreSQL và DuckDB. Để lấy phần khớp ra thay vì thay thế, PostgreSQL dùng substring(text FROM 'pattern') hoặc regexp_substr (từ phiên bản 15), còn DuckDB dùng regexp_extract(text, 'pattern').

TRIM, LTRIM, RTRIM và LPAD

trim xóa khoảng trắng ở đầu và cuối chuỗi; khoảng trắng thừa là lý do phổ biến nhất khiến hai giá trị trông giống nhau lại không khớp khi JOIN:

SELECT '|' || trim('  SVO  ')  || '|' AS t,
       '|' || ltrim('  SVO  ') || '|' AS l,
       '|' || rtrim('  SVO  ') || '|' AS r,
       trim(BOTH '0' FROM '0405') AS z,
       ltrim('0405', '0') AS lz;
t l r z lz
|SVO| |SVO | | SVO| 405 405

Các dấu gạch đứng giúp nhìn thấy khoảng trắng. trim còn có thể xóa ký tự khác, ví dụ các số 0 ở đầu. length(' SVO ') bằng 6 trong khi length(trim(' SVO ')) bằng 3; đây là cách nhanh để tìm giá trị bẩn: WHERE length(code) <> length(trim(code)).

lpad(text, length, fill) làm ngược lại: đệm thêm ký tự vào bên trái. Số hiệu chuyến bay tiếp theo sau PG0405, giữ đủ bốn chữ số:

SELECT flight_no,
       lpad(CAST(CAST(substring(flight_no, 3) AS INTEGER) + 1 AS VARCHAR), 4, '0') AS next_no
FROM flights
WHERE flight_id = 1;
flight_no next_no
PG0405 0406

Văn bản bên trong JSON: ->>

Thông tin liên hệ nằm trong một cột JSON. Toán tử ->> trả về một trường dưới dạng văn bản, và từ đó mọi hàm chuỗi đều dùng được:

SELECT ticket_no,
       contact_data ->> 'phone' AS phone,
       contact_data ->> 'email' AS email,
       right(contact_data ->> 'phone', 4) AS last4
FROM tickets
LIMIT 3;
ticket_no phone email last4
0005432000987 +70127117011 NULL 7011
0005432000988 +70378089255 NULL 9255
0005432000989 +70760429203 NULL 9203

Khóa không tồn tại trả về NULL, không phải chuỗi rỗng. 199.083 trong số 366.733 vé có email, và bạn đếm chúng bằng COUNT(*) FILTER (WHERE contact_data ->> 'email' IS NOT NULL). ->> chạy được trên json và jsonb trong PostgreSQL và trên JSON trong DuckDB.

STRING_AGG: gộp nhiều dòng thành một chuỗi

Ngược lại với tách chuỗi: gộp các giá trị của một nhóm thành một ô, ngăn cách bằng dấu phẩy. Những thành phố có nhiều hơn một sân bay:

SELECT city,
       string_agg(airport_code, ', ' ORDER BY airport_code) AS airports,
       COUNT(*) AS n
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY city;
city airports n
Moscow DME, SVO, VKO 3
Ulyanovsk ULV, ULY 2

string_agg chạy được trong PostgreSQL, DuckDB và SQL Server (từ 2017). MySQL gọi nó là GROUP_CONCAT; Oracle và Snowflake gọi là LISTAGG. HAVING được giải thích trong bài GROUP BY và HAVING.

Tra cứu nhanh: PostgreSQL, DuckDB và các hệ khác

Việc cần làm PostgreSQL DuckDB MySQL SQL Server
Nối chuỗi ||, CONCAT ||, CONCAT CONCAT +, CONCAT
Độ dài length, char_length length CHAR_LENGTH LEN
Tìm vị trí position(x IN s), strpos position, strpos LOCATE, INSTR CHARINDEX
Tách theo dấu phân cách split_part split_part SUBSTRING_INDEX STRING_SPLIT (trả về bảng)
Viết hoa chữ cái đầu mỗi từ initcap không có không có không có
Trích xuất bằng regex substring(s FROM re), regexp_substr regexp_extract REGEXP_SUBSTR không có sẵn
Gộp dòng thành chuỗi string_agg string_agg GROUP_CONCAT STRING_AGG
LIKE không phân biệt hoa thường ILIKE ILIKE tùy collation tùy collation

Các lỗi thường gặp

Ngày giờ có bộ hàm riêng, được trình bày trong bài hàm ngày giờ trong SQL. Muốn luyện thêm với các bài kiểu phỏng vấn thật, hãy xem bài tập SQL có lời giải.

Câu hỏi thường gặp

Nối chuỗi trong SQL như thế nào?

Trong PostgreSQL và DuckDB, dùng first || ' ' || last hoặc CONCAT(first, ' ', last). CONCAT bỏ qua giá trị NULL, còn || trả về NULL nếu có bất kỳ phần nào là NULL. Trong MySQL dùng CONCAT; trong SQL Server dùng + hoặc CONCAT.

SUBSTRING và SPLIT_PART khác nhau thế nào?

SUBSTRING cắt theo vị trí (ký tự thứ 3 đến thứ 6). SPLIT_PART cắt theo dấu phân cách (từ thứ hai sau khi tách theo khoảng trắng). Hãy dùng SPLIT_PART khi các phần có độ dài khác nhau nhưng được ngăn cách bằng một ký tự đã biết.

Xóa khoảng trắng trong chuỗi SQL như thế nào?

trim(x) xóa khoảng trắng ở cả hai đầu, ltrim và rtrim chỉ xóa ở một phía. Để xóa mọi khoảng trắng, kể cả ở giữa, dùng replace(x, ' ', '').

Làm sao so sánh chuỗi không phân biệt hoa thường?

So sánh giá trị đã chuyển về chữ thường, lower(city) = 'moscow', hoặc dùng ILIKE trong PostgreSQL và DuckDB. Trong MySQL và SQL Server, phép so sánh thường đã không phân biệt hoa thường sẵn, tùy theo collation.

PostgreSQL có hàm LEN không?

Không. Hãy dùng length(x) hoặc char_length(x). LEN là hàm của SQL Server; lưu ý LEN của SQL Server bỏ qua khoảng trắng ở cuối, còn length của PostgreSQL trên kiểu text thì vẫn đếm chúng.

Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文