Có hai cách truyền thống để nói chuyện với database trong Go, và cả hai đều có nhược điểm rõ. ORM (như GORM) cho bạn viết Go, nó sinh SQL đằng sau — nhưng SQL đó bị ẩn (khó tối ưu, dễ dính N+1 query), và nó dùng reflection lúc chạy nên chậm hơn. database/sql viết tay cho bạn toàn quyền với SQL, nhưng phải Scan từng cột thủ công vào biến — dễ sai thứ tự, sai kiểu, và tệ nhất: một lỗi gõ sai tên cột chỉ lộ ra lúc chạy, có khi trên production.
sqlc lật ngược bài toán một cách thông minh: bạn viết SQL thật (schema và query), và nó sinh ra code Go an toàn kiểu từ đó. Quan trọng nhất, nó phân tích SQL của bạn so với schema ngay lúc sinh code — nên một cột sai, một kiểu lệch, một cú pháp hỏng bị bắt trước cả khi bạn biên dịch Go. Bài này chạy sqlc thật trên một schema PostgreSQL, xem code nó sinh ra, và đo thật khả năng bắt lỗi sớm.
Ý tưởng: SQL là nguồn sự thật
Với sqlc, bạn khai hai thứ bằng SQL thuần: schema (các CREATE TABLE) và các query, mỗi query kèm một chú thích -- name: dẫn hướng sinh code:
-- schema.sql
CREATE TABLE khach_hang (
id BIGSERIAL PRIMARY KEY, ten TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, diem INTEGER NOT NULL DEFAULT 0);
-- query.sql
-- name: LayKhachTheoID :one
SELECT * FROM khach_hang WHERE id = $1;
-- name: DanhSachKhachTheoDiem :many
SELECT id, ten, email, diem FROM khach_hang
WHERE diem >= $1 ORDER BY diem DESC LIMIT $2;
Hậu tố :one, :many, :exec cho sqlc biết query trả về một hàng, nhiều hàng, hay không trả gì — từ đó nó sinh chữ ký hàm Go tương ứng.
sqlc sinh struct và hàm an toàn kiểu
Chạy sqlc generate, nó đọc schema và query rồi sinh ra code Go. Từ CREATE TABLE ra một struct khớp kiểu:
// models.go — sinh tự động từ CREATE TABLE
type KhachHang struct {
ID int64; Ten string; Email string
Diem int32; TaoLuc pgtype.Timestamptz // kiểu khớp cột SQL
}
Từ mỗi query ra một hàm Go có kiểu vào/ra rõ ràng:
func (q *Queries) DanhSachKhachTheoDiem(ctx, arg DanhSachKhachTheoDiemParams)
([]DanhSachKhachTheoDiemRow, error) { /* Scan tĩnh, không reflection */ }
Điểm mấu chốt là Scan được sinh tĩnh — sqlc viết thẳng rows.Scan(&i.ID, &i.Ten, ...) theo đúng thứ tự cột, không dùng reflection lúc chạy như ORM. Nên nó nhanh ngang database/sql viết tay, mà không có rủi ro Scan sai tay.

Hình 1: sqlc lật ngược quan hệ — SQL là nguồn sự thật, sinh ra Go từ nó: khai schema và query bằng SQL thuần, sqlc sinh struct khớp kiểu và hàm có kiểu vào/ra rõ, gọi hoàn toàn an toàn kiểu để trình biên dịch canh.
Đo thật: sqlc bắt lỗi cột sai ngay lúc sinh
Chạy sqlc generate trên schema và bốn query, nó sinh ba file Go:
OK sinh code
gen/db.go // Queries struct, New(db)
gen/models.go // type KhachHang (khớp CREATE TABLE)
gen/query.sql.go // 4 hàm: LayKhachTheoID, TaoKhach,
// DanhSachKhachTheoDiem, CongDiem
Giờ đến phần giá trị nhất. Thêm một query sai — chọn cột ho_ten không tồn tại (bảng chỉ có ten):
-- SELECT id, ho_ten FROM khach_hang WHERE diem > $1;
Chạy sqlc generate:
db/query/bad.sql:2:12: column "ho_ten" does not exist
sqlc phân tích SQL so với schema và chặn ngay tại dòng 2, cột 12 — trước cả khi bạn biên dịch Go. Với database/sql viết tay, cùng lỗi này chỉ nổ ra lúc chạy truy vấn đó, có khi là lần đầu ai đó gọi tính năng ấy trên production. sqlc kéo lỗi lên sớm hàng tháng.
Sau khi bỏ query lỗi, code sinh ra biên dịch sạch:
$ go build ./...
code sinh ra BIÊN DỊCH SẠCH (type-safe, không reflection)

Hình 2: Đo thật — sqlc sinh 3 file Go từ schema và query; thêm query sai bị chặn ngay tại bad.sql:2:12: column "ho_ten" does not exist trước cả khi biên dịch Go; code sinh ra biên dịch sạch, type-safe, không reflection.
Ba tầng bắt lỗi, càng sớm càng rẻ
Giá trị thật của sqlc là nó đẩy lỗi ngược lên các tầng sớm hơn, nơi sửa rẻ hơn nhiều:
- Lúc sinh (
sqlc generate): kiểm SQL so với schema — cột tồn tại không, kiểu có khớp không, cú pháp có đúng không. Đây là tầng mà ORM và viết tay đều không có. - Lúc build (
go build): trình biên dịch Go kiểm kiểu tham số bạn truyền vào và trường struct bạn đọc ra. Gõk.Tennthay vìk.Tenlà lỗi biên dịch ngay. - Lúc chạy: chỉ còn lại các lỗi thật sự động — mất kết nối, vi phạm ràng buộc unique, timeout. Những lỗi không thể biết trước.
So với đó, database/sql viết tay đẩy lỗi tên cột và lỗi Scan xuống tận lúc chạy; ORM giấu SQL nên lỗi tối ưu (N+1) chỉ lộ khi hồ sơ hiệu năng xấu đi. Nguyên tắc chung của kỹ thuật phần mềm: bắt lỗi càng sớm càng rẻ, và sqlc dịch chuyển cả một lớp lỗi từ "lúc chạy" lên "lúc sinh".
Đánh đổi cần cân nhắc
Thêm một bước generate vào quy trình build. sqlc là một công cụ ngoài, phải chạy sqlc generate lại mỗi khi đổi schema hoặc query, và commit code sinh ra (hoặc sinh trong CI). Đây là chi phí quy trình nhỏ nhưng có thật; nhóm phải quen với việc "đổi SQL thì generate lại".
SQL động khó biểu diễn. sqlc tỏa sáng với query tĩnh, biết trước. Nhưng khi bạn cần query dựng động lúc chạy (ví dụ bộ lọc tùy chọn: WHERE thêm điều kiện tùy tham số người dùng), sqlc không sinh được — bạn phải quay lại viết tay database/sql hoặc dùng query builder cho phần đó. Nhiều dự án dùng sqlc cho phần lớn query tĩnh và viết tay cho số ít query động.
Không phải ORM — không có quan hệ tự động. sqlc không tự nạp quan hệ (không có "eager loading" như ORM). Muốn join thì bạn viết JOIN trong SQL — điều này thực ra tốt (bạn thấy và kiểm soát SQL thật), nhưng là thay đổi tư duy nếu bạn quen ORM lo hết. Đây là triết lý "SQL là ngôn ngữ, hãy dùng nó" chứ không phải giấu nó đi.
Ba ý mang về
- sqlc lật ngược quan hệ SQL–Go: bạn viết SQL thật làm nguồn sự thật, nó sinh code Go an toàn kiểu (struct khớp cột, hàm có kiểu vào/ra rõ, Scan tĩnh không reflection) — nhanh như viết tay, an toàn kiểu như ORM.
- Bắt lỗi SQL ngay lúc sinh: đo thật, sqlc chặn cột
ho_tenkhông tồn tại tạibad.sql:2:12trước cả khi biên dịch Go — cùng lỗi này vớidatabase/sqlviết tay chỉ nổ lúc chạy, có khi trên production. - Ba tầng bắt lỗi sớm dần (sinh → build → chạy) dịch chuyển một lớp lỗi từ lúc chạy lên lúc sinh, càng sớm càng rẻ — đổi lại là thêm bước generate vào build và SQL động vẫn phải viết tay.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho khai báo query sau, hãy nói tên hàm Go mà sqlc generate sẽ sinh ra, và kiểu trả về của nó (một hàng, nhiều hàng, hay không trả gì):
-- name: DanhSachKhachTheoDiem :many
SELECT id, ten, email, diem FROM khach_hang
WHERE diem >= $1 ORDER BY diem DESC LIMIT $2;
Đáp án
Hàm sinh ra tên DanhSachKhachTheoDiem (khớp tên sau -- name:), và vì hậu tố là :many, nó trả về nhiều hàng — chữ ký dạng func (q *Queries) DanhSachKhachTheoDiem(ctx context.Context, arg DanhSachKhachTheoDiemParams) ([]DanhSachKhachTheoDiemRow, error).
Bài 2 (sửa lỗi). Một thành viên trong nhóm thêm query sau vào query.sql rồi chạy sqlc generate và ngạc nhiên khi build thất bại ngay ở bước generate, chưa kịp chạy go build:
-- name: LayHoTen :one
SELECT id, ho_ten FROM khach_hang WHERE id = $1;
Biết bảng khach_hang chỉ có cột ten (không có ho_ten). Giải thích lỗi sẽ xuất hiện ở đâu, và tại sao nó xuất hiện sớm hơn so với dùng database/sql viết tay.
Đáp án
Lỗi sẽ xuất hiện ngay khi chạy sqlc generate, dạng tương tự query.sql:N:M: column "ho_ten" does not exist — sqlc phân tích SQL so với schema thật ngay lúc sinh code, trước cả khi biên dịch Go. Với database/sql viết tay, cùng lỗi gõ sai tên cột này sẽ không bị phát hiện cho tới lúc chạy câu truy vấn (runtime), vì Go biên dịch bình thường với một chuỗi SQL bất kỳ — sqlc đẩy lỗi này lên sớm hơn hẳn, đúng theo bài "sinh → build → chạy" càng sớm càng rẻ.
Bài 3 (vận dụng thực tế). Nhóm của bạn cần một tính năng lọc động: người dùng có thể chọn lọc theo ten, email, hoặc diem, tùy ý kết hợp (WHERE thêm điều kiện tùy chọn theo tham số). Theo đánh đổi nêu trong bài, sqlc có phù hợp để sinh trực tiếp loại query này không? Nên làm gì?
Đáp án
Không phù hợp trực tiếp — bài nói rõ "SQL động khó biểu diễn": sqlc tỏa sáng với query tĩnh, biết trước lúc sinh code. Với bộ lọc tùy chọn cần dựng WHERE động lúc chạy, nên quay lại viết tay bằng database/sql hoặc dùng một query builder cho riêng phần đó, trong khi vẫn dùng sqlc cho phần lớn các query tĩnh còn lại của ứng dụng — đây là cách tiếp cận thực dụng nhiều dự án áp dụng theo bài.
Bài 4 (bẫy/đánh đổi). Một lập trình viên quen ORM phàn nàn: "sqlc không tự join hay nạp quan hệ như GORM, phải tự viết JOIN — vậy nó thụt lùi hơn ORM." Theo triết lý của bài, phản biện lại nhận định này thế nào?
Đáp án
Đây không phải thụt lùi mà là triết lý có chủ đích: "SQL là ngôn ngữ, hãy dùng nó" chứ không giấu nó đi. Việc phải tự viết JOIN trong SQL nghĩa là lập trình viên thấy và kiểm soát đúng câu SQL thật sẽ chạy, tránh được vấn đề N+1 query ẩn mà ORM hay mắc phải khi tự động "eager loading" quan hệ. Cái giá là thay đổi tư duy so với quen ORM lo hết, nhưng đổi lại là minh bạch và kiểm soát hiệu năng tốt hơn.
Bài 5 (đọc hiểu code Go). Đoạn code sau (sinh bởi sqlc) gọi hàm với sai kiểu tham số. Giải thích lỗi này bị bắt ở tầng nào trong "ba tầng bắt lỗi" của bài, và vì sao.
ks, err := q.DanhSachKhachTheoDiem(ctx, gen.DanhSachKhachTheoDiemParams{
Diem: "một trăm", // string thay vì int32
Limit: 10,
})
Đáp án
Lỗi bị bắt ở tầng lúc build (go build), không phải lúc sinh hay lúc chạy — vì trường Diem trong struct DanhSachKhachTheoDiemParams được sqlc sinh với kiểu cụ thể (ví dụ int32, khớp cột diem INTEGER), và trình biên dịch Go sẽ báo lỗi kiểu ngay khi gán một string ("một trăm") vào trường đó — không cần chờ tới lúc chạy như khi dùng interface{} hay tham số chưa kiểm kiểu của database/sql viết tay thuần túy.
Phần sau ta xét một tối ưu quan trọng ở tầng driver: prepared statement caching — vì sao chuẩn bị câu lệnh một lần rồi tái dùng nhanh hơn, cách driver cache chúng, và cạm bẫy với connection pool.