Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch
Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch
Stack: Go 1.26 · pgx/v5 · PostgreSQL 16/17 (verified on 18.6) · HTTP JSON API
A client that does PUT /widgets/42 gets an HTTP 200 body whose updated_at does not equal the value a later GET /widgets/42 returns, even though UPDATE ... SET updated_at = $2 succeeded. The write response is therefore useless as write-confirmation.
There are two independent divergences; the second only matters if you need byte-identical bodies.
PostgreSQL timestamptz stores whole microseconds only. Go's time.Now() carries nanoseconds. The truncation happens in pgx's binary codec before the bytes reach the server. From pgtype/timestamptz.go (v5.7.5):
// binary encode
microsecSinceUnixEpoch := ts.Time.Unix()*1000000 + int64(ts.Time.Nanosecond())/1000
Nanosecond() is always >= 0, so /1000 is truncation toward the past, not rounding. The text codec is explicit: t := ts.Time.UTC().Truncate(time.Microsecond).
The bug is that the service generates an instant once, echoes the raw Go value back, but the row holds the truncated value:
caller's object: 08:37:46.215196658 (658 ns beyond the row)
stored row: 08:37:46.215196 (microsecond grid)
time.Time.Equal compares instants, so these differ. A mock store that stores/returns the Go struct never truncates and passes — hence the real-DB test. Measured over 300 fresh time.Now() samples:
samples=300 stored==Truncate(us): 300/300 stored==Round(us): 154/300
The row matches Truncate, not Round. Round carries up into the next microsecond ~half the time — using Round reintroduces the bug.
pgx decodes timestamptz via time.Unix(...), which returns time.Local:
tim := time.Unix(
microsecFromUnixEpochToY2K/1000000+microsecSinceY2K/1000000,
(microsecFromUnixEpochToY2K%1000000*1000)+(microsecSinceY2K%1000000*1000),
)
if plan.location != nil { tim = tim.In(plan.location) }
The write response built from a UTC instant renders ...Z; a later GET renders ...-05:00. Instants equal, strings not.
Truncate (never Round) and pass that exact value to the driver.UPDATE must not report an unpersisted value.package main
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Widget struct {
ID int64 `json:"id"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct{ pool *pgxpool.Pool }
func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// UpdateUpdatedAt persists ts and returns the instant the API should render.
func (s *Store) UpdateUpdatedAt(ctx context.Context, id int64, ts time.Time) (time.Time, error) {
// (1) The single instant the caller may observe. Truncate matches what the
// driver stores; Round does not.
persisted := ts.UTC().Truncate(time.Microsecond)
tag, err := s.pool.Exec(ctx,
`UPDATE widgets SET updated_at = $2 WHERE id = $1`,
id, persisted,
)
if err != nil {
return time.Time{}, err // nothing persisted; report nothing
}
if tag.RowsAffected() == 0 {
return time.Time{}, fmt.Errorf("widget %d not found", id) // (2) no phantom value
}
// (2) Same value handed to the driver, returned only after success.
return persisted, nil
}
func (s *Store) Get(ctx context.Context, id int64) (Widget, error) {
var w Widget
err := s.pool.QueryRow(ctx,
`SELECT id, updated_at FROM widgets WHERE id = $1`, id,
).Scan(&w.ID, &w.UpdatedAt)
w.UpdatedAt = w.UpdatedAt.UTC() // canonical spelling; drop if byte-identity not needed
return w, err
}
Handler: one instant, render only what the store returns after success:
now := time.Now() // raw is fine; the store owns normalization
widget, err := h.store.UpdateUpdatedAt(r.Context(), id, now)
if err != nil { /* 404/500 */ }
writeJSON(w, Widget{ID: id, UpdatedAt: widget}) // persisted value, not `now`
Schema:
CREATE TABLE IF NOT EXISTS widgets (
id bigint PRIMARY KEY,
updated_at timestamptz NOT NULL
);
export PATH=/usr/lib/postgresql/18/bin:$PATH
rm -rf /tmp/pgdata /tmp/pgsock && mkdir -p /tmp/pgsock
initdb -D /tmp/pgdata -U postgres --auth=trust --no-sync
pg_ctl -D /tmp/pgdata -o "-p 55432 -k /tmp/pgsock -c listen_addresses=<ip-address>" -l /tmp/pg.log start
psql -h <ip-address> -p 55432 -U postgres -c 'CREATE TABLE IF NOT EXISTS widgets (id bigint PRIMARY KEY, updated_at timestamptz NOT NULL);'
export TEST_DATABASE_URL='postgres://postgres@<ip-address>:55432/postgres?sslmode=disable'
Timestamptz resolution is 1 µs on all supported majors, so 18.6 is representative of 16/17.
func TestUpdateResponseMatchesStoredValue(t *testing.T) {
pool := testPool(t) // skips unless TEST_DATABASE_URL is set
st := New(pool)
setup(t, pool, 1)
ctx := context.Background()
caller := time.Now() // nanosecond precision, like the real bug
resp, err := st.UpdateUpdatedAt(ctx, 1, caller)
if err != nil { t.Fatalf("update: %v", err) }
row, err := st.Get(ctx, 1)
if err != nil { t.Fatalf("get: %v", err) }
if !resp.Equal(row.UpdatedAt) {
t.Fatalf("write response instant != stored instant\n response = %s (%d ns)\n stored = %s (%d ns)\n delta = %v",
resp.Format(time.RFC3339Nano), resp.Nanosecond(),
row.UpdatedAt.Format(time.RFC3339Nano), row.UpdatedAt.Nanosecond(),
resp.Sub(row.UpdatedAt))
}
}
=== RUN TestUpdateResponseMatchesStoredValue
store_test.go:62: write response instant != stored instant
response = 2026-09-17T08:37:46.215196658-05:00 (215196658 ns)
stored = 2026-09-17T08:37:46.215196-05:00 (215196000 ns)
delta = 658ns
--- FAIL: TestUpdateResponseMatchesStoredValue (0.00s)
samples=300 stored==Truncate(us): 300/300 stored==Round(us): 154/300
=== RUN TestUpdateResponseMatchesStoredValue
--- PASS: TestUpdateResponseMatchesStoredValue (0.00s)
=== RUN TestZeroRowsMustNotReportUnpersistedValue
--- PASS: TestZeroRowsMustNotReportUnpersistedValue (0.00s)
=== RUN TestRFCS3339Spelling
response RFC3339Nano = 2026-09-17T13:37:58.902632Z
stored RFC3339Nano = 2026-09-17T13:37:58.902632Z
--- PASS: TestRFCS3339Spelling (0.00s)
PASS
ok tztest 0.013s
| pre-fix | fixed | |
|---|---|---|
| PUT | {"id":42,"updated_at":"2026-09-17T08:38:18.271343172-05:00"} |
{"id":42,"updated_at":"2026-09-17T13:38:13.595336Z"} |
| GET | ...271343000... (truncated) |
{"id":42,"updated_at":"2026-09-17T13:38:13.595336Z"} |
| result | FAIL (sub-µs + spelling) | PASS (byte-identical) |
cp store.go /tmp/store.fixed.go
git show <pre-fix-commit>:store.go > store.go # old buggy implementation
go test -run TestUpdateResponseMatchesStoredValue ./... # FAIL
cp /tmp/store.fixed.go store.go
go test ./... # PASS
time.Truncate, never time.Round — PostgreSQL truncates (300/300 vs 154/300 measured).time.Now() again for the response.RowsAffected() > 0; a 0-row update must error, not return a value.TEST_DATABASE_URL so CI runs it..UTC()).TimestamptzCodec.ScanLocation = time.UTC on the pgtype.Map so pgx always decodes in UTC.Solution saved to /workspace/solution.md.
# Evidence - Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T13:39:00.607Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "Go 1.26 + pgx/v5 + PostgreSQL 16/17 timestamptz columns + HTTP JSON API", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-postgres-timestamptz-microsecond-truncation-response-mismatch", "provider": "openrouter", "solved_at": "2026-09-17T13:39:00.607Z", "version": ""}Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch
Stack: Go 1.26 · pgx/v5 · PostgreSQL 16/17 (verified on 18.6) · HTTP JSON API
A client that does PUT /widgets/42 gets an HTTP 200 body whose updated_at does not equal the value a later GET /widgets/42 returns, even though UPDATE ... SET updated_at = $2 succeeded. The write response is therefore useless as write-confirmation.
There are two independent divergences; the second only matters if you need byte-identical bodies.
PostgreSQL timestamptz stores whole microseconds only. Go's time.Now() carries nanoseconds. The truncation happens in pgx's binary codec before the bytes reach the server. From pgtype/timestamptz.go (v5.7.5):
// binary encode
microsecSinceUnixEpoch := ts.Time.Unix()*1000000 + int64(ts.Time.Nanosecond())/1000
Nanosecond() is always >= 0, so /1000 is truncation toward the past, not rounding. The text codec is explicit: t := ts.Time.UTC().Truncate(time.Microsecond).
The bug is that the service generates an instant once, echoes the raw Go value back, but the row holds the truncated value:
caller's object: 08:37:46.215196658 (658 ns beyond the row)
stored row: 08:37:46.215196 (microsecond grid)
time.Time.Equal compares instants, so these differ. A mock store that stores/returns the Go struct never truncates and passes — hence the real-DB test. Measured over 300 fresh time.Now() samples:
samples=300 stored==Truncate(us): 300/300 stored==Round(us): 154/300
The row matches Truncate, not Round. Round carries up into the next microsecond ~half the time — using Round reintroduces the bug.
pgx decodes timestamptz via time.Unix(...), which returns time.Local:
tim := time.Unix(
microsecFromUnixEpochToY2K/1000000+microsecSinceY2K/1000000,
(microsecFromUnixEpochToY2K%1000000*1000)+(microsecSinceY2K%1000000*1000),
)
if plan.location != nil { tim = tim.In(plan.location) }
The write response built from a UTC instant renders ...Z; a later GET renders ...-05:00. Instants equal, strings not.
Truncate (never Round) and pass that exact value to the driver.UPDATE must not report an unpersisted value.package main
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Widget struct {
ID int64 `json:"id"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct{ pool *pgxpool.Pool }
func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// UpdateUpdatedAt persists ts and returns the instant the API should render.
func (s *Store) UpdateUpdatedAt(ctx context.Context, id int64, ts time.Time) (time.Time, error) {
// (1) The single instant the caller may observe. Truncate matches what the
// driver stores; Round does not.
persisted := ts.UTC().Truncate(time.Microsecond)
tag, err := s.pool.Exec(ctx,
`UPDATE widgets SET updated_at = $2 WHERE id = $1`,
id, persisted,
)
if err != nil {
return time.Time{}, err // nothing persisted; report nothing
}
if tag.RowsAffected() == 0 {
return time.Time{}, fmt.Errorf("widget %d not found", id) // (2) no phantom value
}
// (2) Same value handed to the driver, returned only after success.
return persisted, nil
}
func (s *Store) Get(ctx context.Context, id int64) (Widget, error) {
var w Widget
err := s.pool.QueryRow(ctx,
`SELECT id, updated_at FROM widgets WHERE id = $1`, id,
).Scan(&w.ID, &w.UpdatedAt)
w.UpdatedAt = w.UpdatedAt.UTC() // canonical spelling; drop if byte-identity not needed
return w, err
}
Handler: one instant, render only what the store returns after success:
now := time.Now() // raw is fine; the store owns normalization
widget, err := h.store.UpdateUpdatedAt(r.Context(), id, now)
if err != nil { /* 404/500 */ }
writeJSON(w, Widget{ID: id, UpdatedAt: widget}) // persisted value, not `now`
Schema:
CREATE TABLE IF NOT EXISTS widgets (
id bigint PRIMARY KEY,
updated_at timestamptz NOT NULL
);
export PATH=/usr/lib/postgresql/18/bin:$PATH
rm -rf /tmp/pgdata /tmp/pgsock && mkdir -p /tmp/pgsock
initdb -D /tmp/pgdata -U postgres --auth=trust --no-sync
pg_ctl -D /tmp/pgdata -o "-p 55432 -k /tmp/pgsock -c listen_addresses=<ip-address>" -l /tmp/pg.log start
psql -h <ip-address> -p 55432 -U postgres -c 'CREATE TABLE IF NOT EXISTS widgets (id bigint PRIMARY KEY, updated_at timestamptz NOT NULL);'
export TEST_DATABASE_URL='postgres://postgres@<ip-address>:55432/postgres?sslmode=disable'
Timestamptz resolution is 1 µs on all supported majors, so 18.6 is representative of 16/17.
func TestUpdateResponseMatchesStoredValue(t *testing.T) {
pool := testPool(t) // skips unless TEST_DATABASE_URL is set
st := New(pool)
setup(t, pool, 1)
ctx := context.Background()
caller := time.Now() // nanosecond precision, like the real bug
resp, err := st.UpdateUpdatedAt(ctx, 1, caller)
if err != nil { t.Fatalf("update: %v", err) }
row, err := st.Get(ctx, 1)
if err != nil { t.Fatalf("get: %v", err) }
if !resp.Equal(row.UpdatedAt) {
t.Fatalf("write response instant != stored instant\n response = %s (%d ns)\n stored = %s (%d ns)\n delta = %v",
resp.Format(time.RFC3339Nano), resp.Nanosecond(),
row.UpdatedAt.Format(time.RFC3339Nano), row.UpdatedAt.Nanosecond(),
resp.Sub(row.UpdatedAt))
}
}
=== RUN TestUpdateResponseMatchesStoredValue
store_test.go:62: write response instant != stored instant
response = 2026-09-17T08:37:46.215196658-05:00 (215196658 ns)
stored = 2026-09-17T08:37:46.215196-05:00 (215196000 ns)
delta = 658ns
--- FAIL: TestUpdateResponseMatchesStoredValue (0.00s)
samples=300 stored==Truncate(us): 300/300 stored==Round(us): 154/300
=== RUN TestUpdateResponseMatchesStoredValue
--- PASS: TestUpdateResponseMatchesStoredValue (0.00s)
=== RUN TestZeroRowsMustNotReportUnpersistedValue
--- PASS: TestZeroRowsMustNotReportUnpersistedValue (0.00s)
=== RUN TestRFCS3339Spelling
response RFC3339Nano = 2026-09-17T13:37:58.902632Z
stored RFC3339Nano = 2026-09-17T13:37:58.902632Z
--- PASS: TestRFCS3339Spelling (0.00s)
PASS
ok tztest 0.013s
| pre-fix | fixed | |
|---|---|---|
| PUT | {"id":42,"updated_at":"2026-09-17T08:38:18.271343172-05:00"} |
{"id":42,"updated_at":"2026-09-17T13:38:13.595336Z"} |
| GET | ...271343000... (truncated) |
{"id":42,"updated_at":"2026-09-17T13:38:13.595336Z"} |
| result | FAIL (sub-µs + spelling) | PASS (byte-identical) |
cp store.go /tmp/store.fixed.go
git show <pre-fix-commit>:store.go > store.go # old buggy implementation
go test -run TestUpdateResponseMatchesStoredValue ./... # FAIL
cp /tmp/store.fixed.go store.go
go test ./... # PASS
time.Truncate, never time.Round — PostgreSQL truncates (300/300 vs 154/300 measured).time.Now() again for the response.RowsAffected() > 0; a 0-row update must error, not return a value.TEST_DATABASE_URL so CI runs it..UTC()).TimestamptzCodec.ScanLocation = time.UTC on the pgtype.Map so pgx always decodes in UTC.Solution saved to /workspace/solution.md.
# Evidence - Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T13:39:00.607Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "Go 1.26 + pgx/v5 + PostgreSQL 16/17 timestamptz columns + HTTP JSON API", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-postgres-timestamptz-microsecond-truncation-response-mismatch", "provider": "openrouter", "solved_at": "2026-09-17T13:39:00.607Z", "version": ""}