Skip to content

ON CONFLICT DO UPDATE cannot assign an expression, so an upsert's updated_at must come from the app clock #90

Description

@jryannel

Where

mutate.go:115OnConflictUpdate(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions