Sunday, October 25, 2026
Why Do Long-Time Go Users Say Go Is Losing Its Simplicity?
Posted by

Introduction
Long-time Go users say Go is losing its simplicity because the language they learned with a small set of rules now asks them to learn type parameters, module files, workspace files, and a growing standard library, and that growth changes what "reading Go" means. The counter-view, also from long-time users, is that the core stayed small, each addition solved a named problem, and a reader can still write plain Go without using every new feature. Both views describe the same history, and they differ on what counts as simplicity: a small language, or a small amount of work to get a program written.
Before You Start
- Simplicity: in this article, the property of a language or tool that lets a reader understand a program from a small set of rules, without holding many special cases in mind. Simplicity is a judgement about reading, not a measurement, and two readers can judge the same feature differently.
- Type parameter: a placeholder for a type, written in square brackets in a function or type declaration, such as
[T any]. When the function is called, a concrete type fills the placeholder, so one function can work with several types while the compiler still checks types. - Constraint: an interface that limits which types may fill a type parameter. The constraint
anyallows every type,comparableallows types that support==and!=, and a union such asint | float64allows only the listed types. - Module: a collection of packages in a file tree, with a
go.modfile at its root. The module path ingo.modis the identity other modules use to require it, and thegodirective states the minimum Go version for the module. - Workspace: a mode, introduced in Go 1.18, for working with several modules at once, controlled by a
go.workfile. Each module listed in the workspace is treated as a main module while resolving dependencies, so local changes in one module are used by another without editing eachgo.modfile. - Standard library: the packages shipped with Go itself, such as
fmt,strings,slices, andmaps. Growth of the standard library means new packages and new functions in existing packages, which a reader may meet in code written by others. - Iterator: in Go 1.23, a function type that can be used as the expression after
rangein aforloop. Theiterpackage names two such types,iter.Seq[V]for one value per step anditer.Seq2[K, V]for two values per step, and the iterator calls ayieldfunction for each value. - Compatibility promise: the Go 1 compatibility promise, which states that programs written for Go 1 should keep compiling and running under later Go 1 releases, with the same behaviour, apart from a small set of documented exceptions. This article does not rely on the exceptions, and it does not quote the promise text.
Prerequisites
- Go 1.20 or later for the complete example, because generics require Go 1.18 or later, and the example module states Go 1.20.
- A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Tested with: Go 1.22.2, module go 1.20. Verified on 2026-10-25 against the official sources in References.
Project Layout
simplicity-example/
├── go.mod # module declaration, Go version
├── main.go # plain filter, generic filter, main function
└── main_test.go # table tests for both filters
- go.mod: declares the module and the minimum Go version, Go 1.20, which covers the generics used in the example.
- main.go: holds the plain version and the generic version of the same filter, side by side, so the reader can compare the reading cost of each.
- main_test.go: holds table tests for both versions, so the comparison rests on behaviour, not on taste alone.
What Simplicity Meant In Early Go
Early Go sold a small surface. A reader learned a small keyword set, a small type system with no type parameters, interfaces defined by method sets, explicit error returns, goroutines and channels for concurrency, and a toolchain with a small command set. A new package could be read without first learning a framework, because the language gave the reader few shapes to expect, and the standard library gave a small set of packages to know.
That small surface was a choice with a cost, and the cost is part of the story. Without type parameters, a function that worked on several types had to be written once per type, or written over any with a type assertion at run time. Without modules, dependency versions were managed outside the language toolchain. Without a workspace mode, working on two modules at once meant editing a go.mod file with a replace directive, then remembering to remove it before publishing. The additions that followed each addressed one of those costs, and each addition also added one more thing a reader must know.
What Grew: Generics
Generics arrived in Go 1.18, and the Go team's own announcement called them the most significant change to Go since the release of Go 1, and the largest single language change the team had ever made. That framing matters, because it shows the change was not presented as a small tweak. A function or type gained a type parameter list, interfaces gained a second role as type sets, and a reader who met a generic function had to learn constraints, inference, and the limits of what a type parameter may do, such as the rule that methods may not declare their own type parameters.
The case for generics, as the Go team presented it, was duplication. A function such as a slice index search, written for strings, had to be rewritten for integers, or written over any with a loss of compile time checking. One generic function replaces the copies, and the compiler checks the concrete types at each call. The case against generics, as long-time users state it, is reading cost. A signature such as Filter[T any](items []T, keep func(T) bool) []T asks the reader to track a placeholder type, a constraint, and a function value, where the plain version asked for a concrete slice type and a loop. The plain version is longer when several types are needed, and the generic version is harder to read at first sight. Both statements can be true, and the tradeoff depends on how many types the function serves.
Guidance that follows from that design draws the same line. Generics fit operations on language defined container types, such as slices, maps, and channels, where the code makes no specific assumption about the element type, and general purpose data structures, such as a linked list or a binary tree. Generics do not fit a function that only calls one method on its argument, where an interface argument is clearer, and generics are never mandatory. A reader who applies that guidance writes a small number of generic helpers, and concrete code elsewhere, which keeps the generic surface small in a given codebase.
Two earlier articles in this series cover the mechanics in full, and this article does not repeat them. Why Do Go Generics Feel Like A Type Puzzle? covers type parameters, constraints, and inference, and Why Do Teams Overuse Generics In Go? covers the team level decision of when a generic helper earns its place.
What Grew: Modules And Workspaces
Modules changed how a Go program is assembled, and workspaces changed how several modules are developed together. A module is identified by its path, its dependencies are listed in go.mod with versions, and go.sum records checksums for verification. A reader who once opened a package and read code must now also read a module file to know which version of a dependency the code was written against, and a reader who maintains a project must learn go mod tidy, version selection, and the replace directive.
Workspaces, also from Go 1.18, let a developer work on several modules at once without editing each go.mod file. A go.work file lists the modules with use directives, and each listed module is treated as a main module while resolving dependencies. The workspace post from the Go team gives the reason plainly: before workspaces, adding a feature to one module and using it in another meant publishing the first module, or editing the dependent module's go.mod with a replace directive, and then removing that directive before publishing. Workspaces removed that edit cycle, at the price of one more file, one more mode, and one more set of rules about when workspace mode applies, including the GOWORK environment variable and the rule that a go.work file inside a required module has no effect on consumers.
The module and workspace articles in this series cover the failure modes in full. Why Do Go Modules Break When A Repo Moves? covers the module path as identity, Why Are Nested Go Modules Bad? covers the nested module boundary, Why Is Go Work So Frustrating? covers workspace rules and mistakes, and Why Does Go Mod Tidy Change Things I Did Not Expect? covers what tidy loads and why it adds or removes requirements. This article treats modules and workspaces as one layer of growth, not as a repeat of those articles.
What Grew: The Standard Library And Range
The standard library grew in the same period, and the growth is visible in small packages a reader meets often. The slices package, added in Go 1.21, gives helpers for common slice operations, such as sorting, searching, and deleting, and the maps package gives helpers for map keys, values, and copying. A reader who learned Go before those packages must learn them when reading newer code, and a reader who writes new code must decide between a hand written loop, which every Go reader understands, and a library helper, which is shorter but requires knowing the helper exists.
Range grew too, in two steps that are often confused. Go 1.22 added ranging over an integer, so for i := range 10 counts from 0 through 9, and it added method and wildcard patterns to the standard HTTP router, covered in Why Did The Standard HTTP Router Feel Limited For So Long?. Go 1.23 added range over iterator functions, so range accepts a function with a yield parameter, and the iter package names Seq and Seq2 for those functions. An iterator hides a loop behind a function value, which is a new reading shape: the reader must find the iterator, read its yield calls, and check the early exit rule, where yield returning false means the consumer stopped, and the iterator must stop calling it. The benefit is a custom collection that can be ranged over like a slice, without exposing its internals. The cost is one more way to write a loop, and one more contract a reader must hold in mind.
Fuzzing, added as built in support in Go 1.18, is a smaller growth of the same kind. A test file gains a fuzz target, the toolchain generates inputs, and a reader who meets a fuzz target must learn its seed corpus and its failure handling. Each of these additions is small on its own. The "losing simplicity" complaint, as users state it, is about the sum: a reader in 2026 meets more language features, more toolchain modes, and more library helpers than a reader in 2015, and the sum is what feels different.
What Stayed The Same
The counter-view rests on what did not change, and the list is long. Error handling still returns error values explicitly, with no exception mechanism added, as covered in Why Is Go Error Handling So Verbose?. Interfaces are still satisfied without declaration, and small interfaces are still the recommended shape, as covered in Why Is Writing Good Idiomatic Go Harder Than Writing Go That Compiles?. Goroutines, channels, and select still work as before, and the core concurrency shapes are unchanged. Formatting with gofmt still removes formatting debate, and the toolchain still builds a program with a small command set for the common case: go build, go test, and go vet.
The compatibility promise supports the counter-view in one specific way. Old programs, written without generics, modules, or iterators, still compile and run, because no addition removed the old shapes. A program written in 2015 with a loop, a slice, and an error return is still a valid Go program, and a reader who knows only those shapes can still read it. What changed is the set of shapes a reader may meet in new code, not the validity of old code. The counter-view, stated fairly, is that Go stayed simple for the reader who writes plain code, and grew complex only for the reader who must read every codebase, including code that uses every new feature.
Design Decisions And Tradeoffs
| Decision | Benefit | Cost | When it fits |
|---|---|---|---|
| Add generics to the language | One function serves several types, with compile time checking | New reading shape: type parameters, constraints, inference | Container operations and general purpose data structures |
| Add modules and workspaces | Versioned dependencies, multi-module development without go.mod edits | More files, more modes, more rules to learn | Projects with dependencies, and local multi-module work |
Add slices and maps helpers | Shorter code for common operations | A reader must know the helper exists | Common operations already covered by a helper |
| Add range over iterators | Custom collections range like slices | A new loop shape with a yield contract | A collection type with its own iteration order or lazy values |
| Keep error handling explicit | Errors are visible at every call site | More lines, more repetition | Every program, as the default shape |
| Refuse a feature | The language stays small | The problem the feature would solve stays unsolved | When the problem is rare, or a library can solve it |
Complete Working Example
The example filters a list of scores, twice: once with a plain function written for scores only, and once with a generic function that works with any element type and a predicate. Both versions produce the same result, so the reader can judge the reading tradeoff from the code, not from a claim about it.
// go.mod
module example.com/simplicity
go 1.20
// main.go
package main
import "fmt"
// Score is one entry in a list of results.
type Score struct {
Name string
Points int
}
// FilterScores returns the scores with at least min points.
// This version is written for Score only, and its loop is
// visible in full, with no type parameter to track.
func FilterScores(scores []Score, min int) []Score {
var out []Score
for _, s := range scores {
if s.Points >= min {
out = append(out, s)
}
}
return out
}
// Filter returns the items for which keep returns true.
// T is a type parameter, and any is its constraint, so
// Filter works with Score, int, string, or any other type,
// as long as the caller supplies a keep function for it.
func Filter[T any](items []T, keep func(T) bool) []T {
var out []T
for _, item := range items {
if keep(item) {
out = append(out, item)
}
}
return out
}
func main() {
scores := []Score{
{Name: "Ana", Points: 90},
{Name: "Bo", Points: 40},
{Name: "Cy", Points: 75},
}
fmt.Println(FilterScores(scores, 50))
fmt.Println(Filter(scores, func(s Score) bool {
return s.Points >= 50
}))
}
Expected output, from go run ., with both versions returning the same two scores:
[{Ana 90} {Cy 75}]
[{Ana 90} {Cy 75}]
The plain version is clearer at the call site for a reader who knows Score, because the function name says what it filters, and the loop shows the rule. The generic version is shorter across a codebase that filters several types, because one function replaces one copy per type, but its call site asks the reader to check the predicate and the type argument together. A codebase with one filtered type pays the generic reading cost for no gain. A codebase with five filtered types pays the duplication cost in the plain version, and the generic version starts to earn its place. That is the whole tradeoff, in one program.
Testing
// main_test.go
package main
import "testing"
func TestFilterScores(t *testing.T) {
scores := []Score{
{Name: "Ana", Points: 90},
{Name: "Bo", Points: 40},
{Name: "Cy", Points: 75},
}
got := FilterScores(scores, 50)
if len(got) != 2 {
t.Fatalf("FilterScores length = %d, want 2", len(got))
}
if got[0].Name != "Ana" || got[1].Name != "Cy" {
t.Fatalf("FilterScores = %v, want Ana and Cy", got)
}
}
func TestFilterGeneric(t *testing.T) {
tests := []struct {
name string
items []int
keep func(int) bool
want int
}{
{
name: "even numbers",
items: []int{1, 2, 3, 4},
keep: func(n int) bool { return n%2 == 0 },
want: 2,
},
{
name: "no match",
items: []int{1, 3, 5},
keep: func(n int) bool { return n%2 == 0 },
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Filter(tc.items, tc.keep)
if len(got) != tc.want {
t.Fatalf("Filter length = %d, want %d", len(got), tc.want)
}
})
}
}
Run the tests with:
go test ./...
The tests check length and names, not the exact slice value, because Filter returns a nil slice when no item matches, and a length check covers both the nil and the empty case without depending on that detail.
Common Mistakes
- Treating every addition as a requirement. A new feature in a release is not a rule that old code must be rewritten. Plain loops, plain functions, and plain error returns remain valid, and rewriting working code to use a new feature adds risk without changing behaviour.
- Making every helper generic. A helper used with one type, and likely to stay with one type, is clearer as a concrete function. The generic version adds a type parameter, a constraint, and a predicate to every reading, and the reader gains nothing in return.
- Hiding the iteration rule inside an iterator. An iterator that yields values with side effects, or that keeps yielding after
yieldreturns false, breaks the contract the consumer relies on. Keep the iterator loop visible, check theyieldreturn value, and stop on false. - Counting features instead of reading code. A language with more features is not automatically harder to read, and a language with fewer features is not automatically clearer. The reading cost of a codebase is set by the features that codebase actually uses, not by the full feature list.
- Quoting the community view as a team decision. The "losing simplicity" view is a community judgement, and the counter-view is also a community judgement. Neither is a statement from the Go team, and an article that presents either side as an official position misleads the reader.
- Forgetting the module file when judging a program. A program that looks plain may depend on a module version that changes its behaviour, and a program that looks complex may be plain code with a long dependency list. Read
go.modalongside the code, not instead of it.
Production Notes
- Keep the generic surface small. One generic helper per container shape, with a short constraint, is easier to review than a generic helper per call site.
- Prefer the standard library helper when it covers the operation, and prefer a hand written loop when the operation is specific to the domain. The loop is understood by every Go reader, and the helper is understood by readers who know that helper.
- Treat workspace files as local development state. A
go.workfile that leaks into a release build changes which module versions are used, and the release build should be checked with workspace mode off, usingGOWORK=off, so the published module files are the ones that decide. - When a new Go release adds a feature, adopt it in new code first, and leave existing code alone unless the new feature fixes a real problem in that code. A mixed codebase, with some files using a new shape and some using the old shape, is harder to read than either shape alone.
- Review for reading cost, not for feature count. A review comment that asks "does this generic helper serve more than one type" is more useful than a comment that asks "why is this not generic".
Decision Guide
- One type, one use: write a concrete function, with a loop in full. The generic version adds reading cost for no gain.
- Several types, same operation: write one generic function, with a short constraint, and test it with two concrete types. The generic version removes duplication, and the tests show the shared behaviour.
- A custom collection with its own order: consider an iterator, if the collection is ranged over in several places, and the iteration rule is stable. Otherwise, return a slice, which every reader already understands.
- Several modules developed together: use a workspace for local development, and keep
go.workout of the release path. Check the release build withGOWORK=off. - A reader who must learn the language from scratch: start with the plain shapes, loops, error returns, small interfaces, and concrete functions, and add generics, workspaces, and iterators only when a program needs them. The plain shapes are the foundation the newer shapes build on.
Conclusion
Long-time users say Go is losing its simplicity because the language grew: generics added a new way to write functions, modules and workspaces added new files and modes, and the standard library and range added new helpers and new loop shapes, and the sum of those additions is larger than any one of them. The counter-view is that the core did not change, old code still compiles, and every addition was optional, targeted at a named problem, and available to ignore in a codebase that does not need it. The fair summary is that Go grew wider, not deeper: a reader who writes plain code meets the same small core as before, while a reader who reads every codebase meets more shapes than before, and that gap between writing and reading is what the complaint is about.
Summary Of Key Points
- Simplicity, as users judge it, is about reading cost, not about the number of features in a release note.
- Generics, from Go 1.18, removed duplication across types, at the price of a new reading shape with type parameters and constraints.
- Modules and workspaces added versioned dependencies and multi-module development, at the price of new files, modes, and rules.
- The standard library and range grew, with
slices,maps, and, in Go 1.23, range over iterator functions, each adding a helper or a loop shape a reader may meet. - Error handling, interfaces, goroutines, channels, and formatting stayed the same, and old plain code still compiles.
- The "losing simplicity" view and the counter-view are both community judgements, not official positions, and both describe the same history from different sides.
Related Posts
- Why Do Go Generics Feel Like A Type Puzzle?
- Why Do Teams Overuse Generics In Go?
- Why Do Go Modules Break When A Repo Moves?
- Why Are Nested Go Modules Bad?
- Why Is Go Work So Frustrating?
- Why Does Go Mod Tidy Change Things I Did Not Expect?
- Why Did The Standard HTTP Router Feel Limited For So Long?
- Why Is Go Error Handling So Verbose?
- Why Is Writing Good Idiomatic Go Harder Than Writing Go That Compiles?
- Why Does Learning Go Feel Hard Even Though The Syntax Looks Simple?
Learn more:
Additional Resources
- The Go Blog, "Go 1.18 Beta 1 is available, with generics": https://go.dev/blog/go1.18beta1
- The Go Blog, "Get familiar with workspaces": https://golang.google.cn/blog/get-familiar-with-workspaces
- Go Modules Reference: https://go.dev/ref/mod
- The
iterpackage documentation: https://pkg.go.dev/iter - The
slicespackage documentation: https://pkg.go.dev/slices
References
- The Go Blog, "Go 1.18 Beta 1 is available, with generics", https://go.dev/blog/go1.18beta1, accessed 2026-10-25.
- The Go Blog, "Get familiar with workspaces", https://golang.google.cn/blog/get-familiar-with-workspaces, accessed 2026-10-25.
- Go Modules Reference, https://go.dev/ref/mod, accessed 2026-10-25.
- The
iterpackage documentation, https://pkg.go.dev/iter, accessed 2026-10-25. - The
slicespackage documentation, https://pkg.go.dev/slices, accessed 2026-10-25.