The C# to Go series was written against Go 1.22 and 1.23. Go is at 1.27 now and two of those versions are out of support, so this is the Christmas post the landing post promised: the claims in the series that the toolchain has overtaken, each one checked against a current install rather than remembered. Every snippet here was run on go1.27.2.
Container CPU limits arrived in 1.25, not 1.19
The Kubernetes post says "Go respects container resource limits automatically since Go 1.19" and its comparison table repeats it. The 1.19 change was GOMEMLIMIT, which is memory. CPU came much later. The Go 1.25 notes say that on Linux "the runtime considers the CPU bandwidth limit of the cgroup containing the process" and that GOMAXPROCS now defaults to the lower of that limit and the logical CPU count. It also re-reads the limit periodically, so a vertical autoscaler changing the limit under a running pod takes effect without a restart.
Anything older than 1.25 in a container with a CPU limit still needs Uber's automaxprocs, or an explicit GOMAXPROCS in the pod spec. On the toolchain the post was written against, the log line in its example would have printed the node's core count, not the pod's limit.
The image pins are two versions out of support
Every Dockerfile in the containers post starts with golang:1.22, and every job in the GitHub Actions post has go-version: '1.22'. Go supports the two most recent majors, so 1.22 fell out of support when 1.24 shipped in February 2025, and the supported pair as I write is 1.26 and 1.27.
The fix is to stop pinning a minor in two places. The module file already names the version, so let the build read it:
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
and in the Dockerfile, pin to the major and let the go line in go.mod pull a newer toolchain if it needs one:
FROM golang:1 AS builder
The action pins have moved too. The posts use actions/setup-go@v5 and golangci/golangci-lint-action@v4. Setup-go is at v7, and the lint action is at v9, which matters more than a bump usually does because v6 of the action was the last to work with golangci-lint v1. The alpine:3.19 runtime image in the same post is also past its end of life, and golang:1-alpine currently means Alpine 3.24.
The golangci-lint config is the old format, and part of it was already invalid
The linting post ships a .golangci.yml with linters-settings, issues.exclude-rules and govet: check-shadowing: true. golangci-lint v2 shipped in March 2025 with a new config shape, and the migration tool told me something I had not noticed: the check-shadowing key was rejected by the v1 schema too. It had been removed in favour of enable: [shadow] before the post went out.
Running golangci-lint migrate --skip-validation over the post's file produces this:
version: "2"
linters:
enable:
- gosec
- misspell
settings:
errcheck:
check-type-assertions: true
gocyclo:
min-complexity: 15
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- gosec
path: _test\.go
Five of the eight linters have gone from the enable list because errcheck, govet, ineffassign, staticcheck and unused are the v2 default set, and gosimple is gone because it was merged into staticcheck. The install line changed as well. The module path is now github.com/golangci/golangci-lint/v2/cmd/golangci-lint, and the project's own docs say that go install and the tool directive "aren't guaranteed to work" and recommend the binary install script. The post's @latest install would have quietly stayed on v1 forever.
b.N loops where b.Loop goes
The benchmarks post says "that b.N is key" and writes nine benchmarks around it. Go 1.24 added testing.B.Loop, and the release notes list two reasons to prefer it: the benchmark function runs exactly once per -count, so setup runs once, and "function call parameters and results are kept alive, preventing the compiler from fully optimizing away the loop body."
The second one bit the post's very first example. Here is BenchmarkAdd both ways:
func BenchmarkAddN(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
func BenchmarkAddLoop(b *testing.B) {
for b.Loop() {
Add(2, 3)
}
}
| Benchmark | ns/op | What it measured |
|---|---|---|
BenchmarkAddN |
0.23 | an empty loop |
BenchmarkAddLoop |
1.58 | the call |
go1.27.2, darwin/arm64, Apple M4 Max, best of three with -count=3.
The b.N version inlines Add, sees the result is unused and deletes it, so the number is the cost of incrementing i. The post presented that as a working benchmark. Go 1.26 also removed the one cost of switching: b.Loop no longer blocks inlining inside the loop body, so there is no reason left to write the old form. The post's b.ResetTimer() calls go too, since Loop resets the timer on its first call.
omitempty has a replacement, and json has a second package
The JSON post has a whole section called "The omitempty Gotcha" and ends it by reaching for pointers so that zero and absent can be told apart. Go 1.24 added omitzero. The release notes describe it as "clearer and less error-prone than omitempty when the intent is to omit zero values" and call out the case that most often hurt: omitempty never omitted a zero time.Time, because a struct is not one of the "empty" kinds, so every optional timestamp serialised as 0001-01-01T00:00:00Z.
type Empty struct {
When time.Time `json:"when,omitempty"`
}
type Zero struct {
When time.Time `json:"when,omitzero"`
}
// Empty{} marshals as {"when":"0001-01-01T00:00:00Z"}
// Zero{} marshals as {}
omitzero calls IsZero() if the type has one and falls back to the zero value otherwise, which is also the hook you want for your own types. The pointer trick still works and still has a job when zero is a meaningful value you want to send. For the common case, the section is now a one-liner.
The other half of this is Go 1.27 switching encoding/json over to the v2 implementation underneath. I checked what that changes for code written against the old API, because the v2 defaults are stricter, and the answer is less than I had expected. The old encoding/json on 1.27 still accepts duplicate object keys (last one wins) and still replaces invalid UTF-8 with U+FFFD, exactly as it did on 1.25. The release notes say so: "Behavior is preserved, though error message text may differ." The strict behaviour lives in the new encoding/json/v2 package, which rejects both:
jsontext: duplicate object member name "a"
jsontext: invalid UTF-8 after offset 1
So the migration risk is in choosing to import v2, not in bumping the toolchain. If you consume JSON from systems you do not control, that is the moment to test. Coming from System.Text.Json, which is strict by default, the v2 behaviour will feel like home.
What did not need correcting
The things I expected to have to walk back held up. Goroutines against async/await, the error handling argument, the single binary deployment story and the escape analysis post all read the same against 1.27 as they did against 1.22. The language has added range-over-func iterators and, this August, generic methods, and the iterators post covers both. Everything else was a version number.
Each of the six posts above now carries a dated note pointing here, and the code in them stays as it was. The series is a record of what Go looked like when I learned it, and the notes are the diff.
Top comments (0)