GORM 查詢助手把 SQL 變護欄
我拆 GORM 進階查詢,整理成可直接抄的模式,讓你少選欄位、少踩鎖、少寫原始 SQL。

我把 GORM 進階查詢拆成一套可直接抄的安全寫法,適合拿來做精簡讀取、鎖列、子查詢和 upsert。
我用 GORM 有一陣子了,越用越知道它哪裡開始煩。基本 CRUD 很順,大家都會寫;但專案一大,model 一肥,查詢就開始失控。不是選太多欄位,就是鎖得不夠,還有那種先查再寫、兩個 request 同時進來、最後資料互打臉的老問題。這些都不是 GORM 壞掉,是我以前太愛偷懶,覺得 query 先能跑再說。
後來我一直回去看 GORM Advanced Query 這頁,因為它講的不是炫技,是怎麼把查詢寫成護欄。少拿資料、需要時鎖住、複雜條件別硬拼字串、找不到就初始化或建立,這些都很務實。你不會因為它而拍手,但你會少收到 bug report。
別再把整包 model 撈回來
訂閱 AI 趨勢週報
每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。
不會寄垃圾信,隨時可取消。
In GORM, you can efficiently select specific fields using the Select method.
翻譯一下就是:你根本不需要每次都把整個 table 的欄位全撈回來。很多 API 只要 id、name、status,結果我以前還是直接 Find(&users),把一堆根本沒人看的欄位一起掃進來,浪費效能也增加暴露風險。

GORM 很適合拿小 struct 當讀模型。你查進 APIUser 這種 DTO,框架會自動只選對應欄位。這件事看起來很普通,但 table 一旦變寬,差異就很明顯。尤其是高流量 endpoint,少幾個欄位就是少一點掃描成本,少一點 payload,也少一點我半夜改欄位時手滑出事。
我之前遇過一個後台列表頁,原本只是顯示帳號與狀態,後來 model 越長越像履歷表。前端只要三欄,後端卻每次都把十幾欄塞回去。那時我改成專門的 read struct 之後,整個 query 就乾淨很多,還順手把不該出現在 API 的欄位隔離掉。
type User struct {
ID uint
Name string
Age int
Gender string
// 其他很多欄位
}
type APIUser struct {
ID uint
Name string
}
db.Model(&User{}).Limit(10).Find(&APIUser{})
// SQL: SELECT `id`, `name` FROM `users` LIMIT 10實操寫法很簡單:先把「讀模型」和「寫模型」分開。列表頁、報表、背景任務,全部都可以用小 struct。真的要全欄位時再回頭用完整 model。這不是多此一舉,這是在幫未來的自己少收爛帳。
- API 回傳只需要少數欄位,就別拿完整 model。
- 用
Select或小 struct,讓欄位意圖寫在程式碼裡。 - 先看一次 SQL,再決定要不要繼續猜。
鎖列不是多餘,是你在避免互搶資料
GORM supports different types of locks, for example: db.Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users)
也就是說,有些「讀」其實只是寫入前的前置動作。你先看一眼資料,再根據它做更新,這時候如果不鎖列,另一個 transaction 很可能在你眼前把同一筆資料改掉。你以為自己在做合理流程,資料庫其實在旁邊看你翻車。
GORM 用 clause.Locking 把 FOR UPDATE、SHARE、NOWAIT、SKIP LOCKED 包起來。最常見的是 UPDATE,表示你要先把這些列鎖住,直到 transaction 結束。這在工作隊列、庫存扣減、任務認領這類流程很實用。兩個 worker 同時搶同一筆資料,這種事我真的看過太多次。
我自己最常用的是 SKIP LOCKED。因為很多背景 worker 不該卡在那邊等,等到超時只會拖垮整批工作。你要嘛快點失敗,要嘛直接跳過別人已經拿走的列,繼續處理下一筆。這比「先查到再說」的寫法可靠太多。
db.Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users)
// SQL: SELECT * FROM `users` FOR UPDATE實操寫法:把鎖放進 transaction,鎖住後只做必要的 DB 操作,不要在裡面打 API、不要等外部服務、不要把 transaction 開著聊天。鎖列的重點不是「看起來很專業」,而是把會互撞的流程收斂成可預期的順序。
UPDATE適合讀完就要改的流程。NOWAIT適合你寧願失敗也不想等。SKIP LOCKED適合多 worker 並行處理。
子查詢比原始 SQL 亂拼好太多
Subqueries are a powerful feature in SQL, allowing nested queries.
翻譯一下就是:你不用把 SQL 字串拼到像手工炸彈。GORM 允許你直接把一個 *gorm.DB 當成子查詢塞進去,這樣條件、括號、參數綁定都還是框架幫你處理,不用自己在那邊補引號、補括號、補到最後整段看不懂。

最典型的例子,就是拿某個欄位去比對平均值或群組結果。這種情況如果硬寫 raw SQL,通常會變成一坨沒人想碰的字串。用 subquery 的話,意圖清楚很多:外層做過濾,內層做計算,兩邊分工明白。
我以前寫報表時最常犯的錯,就是把複雜邏輯直接塞進一段 raw query。當下很快,半年後沒人敢改。後來我改成先定義子查詢,再把它丟進外層條件,維護成本就差很多。這種寫法不是炫技,是避免程式碼變成考古現場。
db.Where("amount > (?)", db.Table("orders").Select("AVG(amount)")).Find(&orders)
// SQL: SELECT * FROM "orders" WHERE amount > (SELECT AVG(amount) FROM "orders");你也可以把子查詢放進 FROM,把一段已經過濾或投影過的資料當成臨時表來用。這招很適合做中介資料集,不用真的去建 temp table,也不用把邏輯拆到三個地方。
db.Table("(?) as u", db.Model(&User{}).Select("name", "age")).Where("age = ?", 18).Find(&User{})
// SQL: SELECT * FROM (SELECT `name`,`age` FROM `users`) as u WHERE `age` = 18實操寫法:只要你開始重複同一段過濾條件,或需要拿某個計算結果去比對另一層資料,就先想子查詢。真的太長就先幫它取名,不要把所有邏輯都塞在一行裡。那種寫法看起來很精簡,其實只是把麻煩延後。
條件一複雜,括號就會開始背叛你
Group Conditions in GORM provide a more readable and maintainable way to write complex SQL queries involving multiple conditions.
也就是說,當 AND、OR 開始混在一起時,別再相信你手寫字串時的記憶力。你少一個括號,整個條件語意就會歪掉,而且通常是那種看起來「差不多」但結果完全不對的歪法,最難抓。
GORM 的 group conditions 很像把 SQL 結構拆成積木。你用巢狀 db.Where(...) 去表達條件群組,程式碼雖然比較長,但邏輯會跟 SQL 一樣清楚。這對篩選器、進階搜尋、後台查詢最有用,因為使用者選項一多,條件就很容易炸開。
我自己最討厭的就是那種「先寫一段 WHERE 字串,之後再補條件」的模式。每個人都覺得自己有記得原本的優先順序,結果一改就壞。group conditions 至少把優先順序寫進結構裡,不用靠人腦硬撐。
db.Where(
db.Where("pizza = ?", "pepperoni").Where(db.Where("size = ?", "small").Or("size = ?", "medium")),
).Or(
db.Where("pizza = ?", "hawaiian").Where("size = ?", "xlarge"),
).Find(&Pizza{})
// SQL: SELECT * FROM `pizzas` WHERE (pizza = "pepperoni" AND (size = "small" OR size = "medium")) OR (pizza = "hawaiian" AND size = "xlarge")實操寫法:只要你看到一個 WHERE 裡面有兩層以上的 AND/OR,就不要硬拼字串。改成巢狀條件,讓每一層對應一個業務規則。你會少掉很多「欸怎麼這筆也被查出來」的問號。
- 複雜條件先拆群組,不要先求短。
- 每個群組對應一條業務規則,之後比較好改。
- 你看得懂,接手的人才有機會看得懂。
命名參數很無聊,但它真的比較不會出事
GORM enhances the readability and maintainability of SQL queries by supporting named arguments.
翻譯一下就是:別再把一堆 ? 當成樂高亂堆,然後自己數到眼花。命名參數不是花俏功能,它是讓查詢更容易讀,也比較不容易把值塞錯位置。尤其是同一個值要重複出現兩次以上時,這招很好用。
GORM 支援 sql.Named 跟 map[string]interface{}。我通常看情境選。重點不是哪個比較潮,而是哪個在那段程式裡比較好掃一眼就懂。因為 query helper 的價值,本來就是讓人少猜。
我以前很愛用位置參數,覺得簡單俐落。後來 helper 一層包一層,參數順序開始像在玩接龍。改成命名參數後,至少我不用再回頭翻函式簽名確認這個值到底是 name 還是 alias。這種小事,寫多了就知道有差。
db.Where("name1 = @name OR name2 = @name", sql.Named("name", "jinzhu")).Find(&user)
// SQL: SELECT * FROM `users` WHERE name1 = "jinzhu" OR name2 = "jinzhu"實操寫法:有重複值、動態條件、helper 函式層級偏深的查詢,優先用 named args。小查詢還是可以用位置參數,但一旦開始長出分支,命名參數通常比較不會讓你自己看不懂。
sql.Named適合讀起來像一般函式參數。map[string]interface{}適合動態組裝值。- 選能讓下一個人最快理解的寫法。
FirstOrInit 和 FirstOrCreate 才像真的 find-or-make
FirstOrInit fetches the first record that matches given conditions, or initializes a new instance if no matching record is found.
也就是說,你可以把「先找,找不到就準備一個」這件事濃縮成一個呼叫。FirstOrInit 是先找資料,沒有就初始化 struct;FirstOrCreate 則是沒有就直接建立。這兩個名字很像,但差很多。
我看過太多人自己手寫三段式流程:查詢、判斷、初始化、可能再存一次。能跑,但很吵,而且很容易漏掉某個條件。GORM 這裡的重點,是讓你用 Attrs 跟 Assign 決定值怎麼進來。Attrs 只在沒找到時補預設值,Assign 則是找到與否都會套上去。
這個差異很實用。你想要的是「如果沒有就給一個預設狀態」,那用 Attrs;你想要的是「不管有沒有找到,都先把 in-memory 物件更新成最新狀態」,那用 Assign。如果要真的寫回資料庫,再用 FirstOrCreate。這樣你的程式碼就不用再自己分兩條路。
db.Where(User{Name: "non_existing"}).Attrs(User{Age: 20}).FirstOrInit(&user)
// user -> User{Name: "non_existing", Age: 20} if not found
db.Where(User{Name: "Jinzhu"}).Assign(User{Age: 20}).FirstOrInit(&user)
// user -> User{ID: 111, Name: "Jinzhu", Age: 20} if found我自己會把它當成「可控版 upsert 前置流程」。但要注意,這不是萬能解法。高併發下還是要靠唯一索引,不然兩個 request 同時找不到、同時建立,照樣會撞車。這種事我不想再用運氣賭。
result := db.FirstOrCreate(&user, User{Name: "non_existing"})
// result.RowsAffected => 1 when created
result = db.Where(User{Name: "jinzhu"}).FirstOrCreate(&user)
// result.RowsAffected => 0 when found實操寫法:需要先準備資料但不急著寫入,就用 FirstOrInit。需要查不到就建立,就用 FirstOrCreate。如果這條路徑會被很多 worker 同時跑,先補唯一鍵,別把資料一致性寄託在程式碼運氣上。
動態結果就別硬塞回 struct
GORM provides flexibility in querying data by allowing results to be scanned into a map[string]interface{} or []map[string]interface{}.
翻譯一下就是:有些查詢本來就不是給固定 schema 用的。像後台報表、匯出、探索性查詢,結果欄位可能會跟著設定變動。這時候硬做一個假 model 只是自找麻煩,GORM 直接掃進 map[string]interface{} 或 slice of maps 反而比較合理。
不過這裡有個小雷點:你還是要先指定 Model 或 Table,不然 GORM 不知道要查哪裡。這種錯很低級,但很常發生,因為大家一看到 map 就以為可以亂來。
我會把這個寫法留給真正動態的場景。比如管理後台要根據使用者選的欄位輸出結果,或是某些臨時分析工具。只要 schema 是固定的,我還是會回到 struct。因為 struct 比 map 更能保護你少打錯欄位名。
result := map[string]interface{}{}
db.Model(&User{}).First(&result, "id = ?", 1)
// SQL: SELECT * FROM `users` WHERE id = 1 LIMIT 1
var results []map[string]interface{}
db.Table("users").Find(&results)
// SQL: SELECT * FROM `users`實操寫法:如果輸出形狀會變,就用 map;如果輸出形狀固定,就用 struct。不要因為懶得定義型別就把所有查詢都變成 map,最後你會得到一堆難以維護的動態資料處理。
可抄的模板
// GORM advanced query playbook
// 直接複製到專案裡,再把 model 名稱改掉。
package data
import (
"database/sql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type User struct {
ID uint
Name string
Age int
Status string
}
type APIUser struct {
ID uint
Name string
}
// 1) 只撈需要的欄位
func ListAPIUsers(db *gorm.DB, limit int) ([]APIUser, error) {
var users []APIUser
err := db.Model(&User{}).
Limit(limit).
Find(&users).Error
return users, err
}
// 2) 在 transaction 裡鎖列
func ClaimUsers(db *gorm.DB) ([]User, error) {
var users []User
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Clauses(clause.Locking{
Strength: "UPDATE",
Options: "SKIP LOCKED",
}).Where("status = ?", "pending").Find(&users).Error
})
return users, err
}
// 3) 用子查詢,不要自己拼字串
func UsersAboveAverageAge(db *gorm.DB) ([]User, error) {
var users []User
avgAge := db.Model(&User{}).Select("AVG(age)")
err := db.Where("age > (?)", avgAge).Find(&users).Error
return users, err
}
// 4) 複雜條件用 group conditions
func FindPizzaCandidates(db *gorm.DB) error {
return db.Where(
db.Where("pizza = ?", "pepperoni").Where(
db.Where("size = ?", "small").Or("size = ?", "medium"),
),
).Or(
db.Where("pizza = ?", "hawaiian").Where("size = ?", "xlarge"),
).Error
}
// 5) 重複值用 named arguments
func FindBySharedName(db *gorm.DB, name string) (*User, error) {
var user User
err := db.Where("name1 = @name OR name2 = @name", sql.Named("name", name)).First(&user).Error
return &user, err
}
// 6) 只初始化,不立刻寫入
func InitUser(db *gorm.DB, name string) (*User, error) {
var user User
err := db.Where(User{Name: name}).Attrs(User{Status: "new"}).FirstOrInit(&user).Error
return &user, err
}
// 7) 找不到就建立,並回傳是否新建
func UpsertUser(db *gorm.DB, name string) (*User, bool, error) {
var user User
result := db.Where(User{Name: name}).Assign(User{Status: "active"}).FirstOrCreate(&user)
created := result.RowsAffected == 1
return &user, created, result.Error
}
// 8) 動態結果直接掃進 map
func LoadDynamicUsers(db *gorm.DB) ([]map[string]interface{}, error) {
var rows []map[string]interface{}
err := db.Table("users").Find(&rows).Error
return rows, err
}這版模板是我會真的留在專案裡的那種,不是貼完就忘的範例。重點不是每一行都照抄,而是你下次寫 query 時,知道該先選欄位、再想鎖、再想子查詢,最後才考慮要不要 raw SQL。
原始來源是 https://gorm.io/docs/advanced_query.html,另外我也對照了 GORM 官方文件裡的 Locking、FirstOrInit、FirstOrCreate。上面這篇是我自己把文件重組成比較好直接上手的版本,模板與解釋屬於衍生整理。