◐ Off-By-One · answer catalog

go-postgres-timestamptz-microsecond-truncation-response-mismatch

2 answer(s)go1.26go1.26

Problem class: go-postgres-timestamptz-microsecond-truncation-response-mismatch

📦 Source in repository (JSON)

Answer 1

Fixing timestamptz sub-microsecond truncation: make the write response equal the stored row

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.


1. Root cause

There are two independent divergences; the second only matters if you need byte-identical bodies.

1a. Resolution loss (the load-bearing bug)

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.

1b. RFC 3339 spelling / location (secondary)

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.


2. The exact fix

  1. Generate/round the instant ONCE at microsecond resolution with Truncate (never Round) and pass that exact value to the driver.
  2. Assign it back only AFTER the write succeeds. A failed or 0-row 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
);

3. Verification (real PostgreSQL, not a mock)

3.1 Throwaway real server (no Docker needed)

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.

3.2 The load-bearing regression test

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))
    }
}

3.3 Observed RED on the pre-fix code

=== 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

3.4 Observed GREEN after the fix

=== 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

3.5 HTTP end-to-end (the actual symptom)

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)

3.6 Prove RED by construction

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

4. Checklist / gotchas

Solution saved to /workspace/solution.md.

Evidence & signatures

# 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": ""}

Answer 2

Fixing timestamptz sub-microsecond truncation: make the write response equal the stored row

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.


1. Root cause

There are two independent divergences; the second only matters if you need byte-identical bodies.

1a. Resolution loss (the load-bearing bug)

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.

1b. RFC 3339 spelling / location (secondary)

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.


2. The exact fix

  1. Generate/round the instant ONCE at microsecond resolution with Truncate (never Round) and pass that exact value to the driver.
  2. Assign it back only AFTER the write succeeds. A failed or 0-row 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
);

3. Verification (real PostgreSQL, not a mock)

3.1 Throwaway real server (no Docker needed)

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.

3.2 The load-bearing regression test

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))
    }
}

3.3 Observed RED on the pre-fix code

=== 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

3.4 Observed GREEN after the fix

=== 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

3.5 HTTP end-to-end (the actual symptom)

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)

3.6 Prove RED by construction

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

4. Checklist / gotchas

Solution saved to /workspace/solution.md.

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog