Where
mutate.go:115 — OnConflictUpdate(target []string, update ...string) takes column names only, and mutate.go:197-211 renders each one as col = EXCLUDED.col. There is no other spelling: OnConflictDoNothing and OnConflictUpdate are the whole ON CONFLICT surface.
What
An upsert cannot assign anything but the proposed row's own value. All three of these have no spelling:
ON CONFLICT (key) DO UPDATE SET updated_at = NOW() -- database clock
ON CONFLICT (key) DO UPDATE SET count = table.count + 1 -- accumulate
ON CONFLICT (key) DO UPDATE SET x = COALESCE(EXCLUDED.x, table.x) -- keep on null
The updated_at = NOW() case is the common one, and the workaround is not neutral: computing the timestamp in Go moves its source from the database clock to the application clock, and forces the column into the INSERT list so it can be echoed back by EXCLUDED. For a table whose other timestamps come from Postgres, one column now disagrees with the rest under clock skew.
This is the gap docs/release-1.0.md:260 already records as Before 1.0, ranked by the multi-app port as "highest-value sqlb change surfaced by this port". Filing it so the roadmap entry has a tracking issue — there is none today, and a consumer reading the tracker to decide whether to wait for it finds nothing.
Both upserts in the multi-app port (core/secrets, core/llmcatalog) hit it. A second external evaluation (2026-08-01, studio-apps) independently names it that consumer's single highest-value upstream ask.
Fix
Admit an expression on the right-hand side of the assignment, keeping the bare-column form as the shorthand it already is. The Expr vocabulary exists; what is missing is a way to say which side of the conflict a column refers to, since EXCLUDED.x and <table>.x are both in scope inside DO UPDATE:
OnConflictUpdate([]string{"key"}, "name").
Set("updated_at", sqlb.Now()).
Set("count", sqlb.Excluded("count").Plus(sqlb.Current("count")))
Two constraints worth settling in the same change:
- The qualifier is not optional and should not be guessed.
Excluded(col) and Current(col) (or whatever they end up called) are what make count = count + 1 unambiguous, and a bare F("count") inside DO UPDATE is ambiguous enough that it should probably be refused rather than defaulted.
- Whatever the column reference is, it must go through the same
i.model.Column(name) == nil check the current renderer does at mutate.go:206, so a typo stays a compile-time-ish error rather than a 42703 at request time.
The parameterised form matters too — SET expires_at = $1 inside DO UPDATE — which means the assignment list has to participate in the same bind-parameter numbering as the VALUES list, not a separate one.
Surfaced by the multi-app adoption port and re-raised by an external evaluation on 2026-08-01.
Where
mutate.go:115 —
OnConflictUpdate(target []string, update ...string)takes column names only, and mutate.go:197-211 renders each one ascol = EXCLUDED.col. There is no other spelling:OnConflictDoNothingandOnConflictUpdateare the wholeON CONFLICTsurface.What
An upsert cannot assign anything but the proposed row's own value. All three of these have no spelling:
The
updated_at = NOW()case is the common one, and the workaround is not neutral: computing the timestamp in Go moves its source from the database clock to the application clock, and forces the column into the INSERT list so it can be echoed back byEXCLUDED. For a table whose other timestamps come from Postgres, one column now disagrees with the rest under clock skew.This is the gap
docs/release-1.0.md:260already records as Before 1.0, ranked by the multi-app port as "highest-value sqlb change surfaced by this port". Filing it so the roadmap entry has a tracking issue — there is none today, and a consumer reading the tracker to decide whether to wait for it finds nothing.Both upserts in the multi-app port (
core/secrets,core/llmcatalog) hit it. A second external evaluation (2026-08-01, studio-apps) independently names it that consumer's single highest-value upstream ask.Fix
Admit an expression on the right-hand side of the assignment, keeping the bare-column form as the shorthand it already is. The
Exprvocabulary exists; what is missing is a way to say which side of the conflict a column refers to, sinceEXCLUDED.xand<table>.xare both in scope insideDO UPDATE:Two constraints worth settling in the same change:
Excluded(col)andCurrent(col)(or whatever they end up called) are what makecount = count + 1unambiguous, and a bareF("count")insideDO UPDATEis ambiguous enough that it should probably be refused rather than defaulted.i.model.Column(name) == nilcheck the current renderer does at mutate.go:206, so a typo stays a compile-time-ish error rather than a 42703 at request time.The parameterised form matters too —
SET expires_at = $1insideDO UPDATE— which means the assignment list has to participate in the same bind-parameter numbering as the VALUES list, not a separate one.Surfaced by the multi-app adoption port and re-raised by an external evaluation on 2026-08-01.