> Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily. It takes the place of using the go keyword, [...]
99% of the time, you don't want to use sync.WaitGroup, but rather errgroup.Group. This is basically sync.WaitGroup with error handling. It also has optional context/cancellation support. See https://pkg.go.dev/golang.org/x/sync/errgroup
I know it's not part of the standard library, but it's part of the http://golang.org/x/ packages. TBH, golang.org/x/ is stuff that should be in the standard library but isn't, for some reason.
I thought exactly the same thing. I use errgroup in practically every Go project because it does something you'd most likely do by hand otherwise, and it does it cleaner.
I discovered it after I had already written my own utility to do exactly the same thing, and the code was almost line for line the same, which was pretty funny. But it was a great opportunity to delete some code from the repo without having to refactor anything!
I never used errgroup but I realize that it's essentially the same what I end up implementing anyways.
With standard waitgroups I always move my states as a struct with something like a nested *data struct and an err property which is then pushed through the channel. But this way, my error handling is after the read instead of right at the Wait() call.
There is mention of how len() is bytes, not “characters”. A further subtlety: a rune (codepoint) is still not necessarily a “character” in terms of what is displayed for users — that would be a “grapheme”.
A grapheme can be multiple codepoints, with modifiers, joiners, etc.
Did not know about index-based string interpolation. Useful!
The part about changing a map while iterating is wrong though. The reason you may or may not get it is because go iterates in intentionally random order. It's nothing to do with speed. It's to prevent users from depending on the iteration order. It randomly chooses starting bucket and then goes in circular order, as well as randomly generates a perm of 0..7 inside each bucket. So if your edit goes into a bucket or a slot already visited then it won't be there.
Also, python is not an example to the contrary. Modifying python dicts while iterating is a `RuntimeError: dictionary changed size during iteration`
Great list of why one can love and hate Go. I really did enjoy writing it but you never get the sense that you can be truly certain your code is robust because of subtle behaviour around nil.
I guess as a corollary, Go really rewards writing the dumbest code possible. No advanced type shenanigans, no overuse of interfaces, no complex composition of types. Then you will end up with a very fast, resource light system that just runs forever.
>As an additional complexity, although string literals are UTF-8 encoded, they are just aribtrary collections of bytes, which means you can technically have strings that have invalid data in them. In this case, Go replaces invalid UTF-8 data with replacement characters.
No, it's just doing the usual "replace unprintable characters when printing" behavior. The data is unchanged, you have no guarantees of UTF-8 validity at all: https://go.dev/play/p/IpYjcMqtmP0
> This is different than, for instance, python, which has a “stable insertion order” that guarantees that this won’t happen. The reason Go does this: speed!
In Python you'll actually get a RuntimeError here, because Python detects that you're modifying the dictionary while iterating over it.
I'm amused by posts like this because it shows that Go is finally slowly moving away from being an unergonomically simplistic language (its original USP?) to adopt features a modern language should have had all along.
My experience developing in it always gave me the impression that the designers of the language looked at C and thought "all this is missing is garbage collection and then we'll have the perfect language".
I feel like a large amount of the feeling of productivity developers get from writing Go code originates from their sheer LOC output due to having to reproduce what other languages can do in just a few lines thanks to proper language & standard library features.
If you don't write Go at all, this blog post isn't going to be useful to you, and you aren't its audience. It's fine not to have an apt take for a programming-language-specific article!
The wording "Subtleties" used here is some weird/improper. I see nothing subtle here. They are all basic knowledge a qualified Go programmer should know about.
> This is helpful if you have to interpolate the same value multiple times and want to reduce repetition and make the interpolation easier to follow.
Is index-based string interpolation easier to follow? I would find it easier to understand a string interpolation when the variable name is right there, rather than having to count along the arguments to find the particular one it's referencing
I balked a little when the article refers to format strings as "string interpolation" but there's multiple comments here running with it. Am I out of date and we just call that string interpolation these days?
I also found this very confusing:
> When updating a map inside of a loop there’s no guarantee that the update will be made during that iteration. The only guarantee is that by the time the loop finishes, the map contains your updates.
That's totally wrong, right? It makes it sound magical. There's a light explainer but I think it would be a lot more clear to say that of course the update is made immediately, but the "range" iterator may not see it.
I think people get bizarrely hung up on the tiny details of this between languages... but then, I think that extensive use of string interpolation is generally a code smell at best anyhow, so I'm probably off the beaten path in more than one way here.
Indeed, I have always heard such techniques as "string formatting" while built-in-to-the-language local-variable implicit string formatting sugar syntax is the thing I've heard called "string interpolation".
In Python, calling "{}".format(x) is string formatting, while string interpolation would be to use the language feature of "f-strings" such as f"{x}" to do the same thing. As far as I know, go doesn't have string interpolation, it only has convenient string formatting functions via the fmt package.
Basically, if you format strings with a language feature: interpolation. If you use a library to format strings: string formatting.
Go's subtle footguns are definitely its worst aspect. I say that as a "Go fanboy" (I confess). But I think its also worth asking WHY many of these footguns continue to exist from early Go versions - and the answer is that Go takes versioning very seriously and sticking to major version 1 very seriously.
The upshot of this dogmatism is that its comparatively easy to dev on long-lived Go projects. If I join a new team with an old Go project, there's a very good chance that I'll be able to load it up in my IDE and get all of Go's excellent LSP, debug, linting, testing, etc. tooling going immediately. And when I start reading the code, its likely not going to look very different from a new Go project I'd start up today.
(BTW Thanks OP for these subtleties, there were a few things I learned about).
Yeah, having mutability optional would be great. It would also allow a lot of data to pass through the stack instead of heap due to pointers, which Go is riddled with for absolutely no reason(imo).
On the other hand, now that we have iterators in Go, you can create a wrapper for []byte that only allows reading, yet is iterable.
But then we're abstracting away, which is a no-go in Go and also creates problems later on when you get custom types with custom logic.
Go has certainly come a long ways from its initial mission to be a simple language for Rob Pike's simple coworkers.
type User struct {
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"email"`
}
So you can specify how to serialize a struct in json using raw string literals containing arbitrary metadata. And json:"X" means to serialize it to X, except the special value "-" means "omit this one," except "-," means that its name is "-". Got it.
I never liked the concept of struct tags, it's a kind of stringly typed programming where the meaning of X or - depends entirely on what the json package says it means.
An alternative is to introduce something like annotations, but I'm sure there will be resistance as it makes the language lean closer to e.g. Java.
But my take on that is that if you want stricter typing like that, you should actually go to Java or C# or whatever.
Of all the things one might critique Go for as not being simple, I'm not sure this is it. I've never needed to serialize "-" as a key in JSON but `-,` makes some sense given the general pattern of field tags, e.g. `json:"name,omitempty"`
I had a “wtf” moment when using Go around panic() and recover()
I was so surprised by the design choice to need to put recover in in deferred function calls. It’s crazy to smush together the error handling and normal execution code.
It's cause it's not normal error handling to use recover(). In smaller codebases, panic probably should not be present. For larger codebases, recover should be in place only in very very sparse locations (e.g. at the top level http handler middleware to catch panics caused by unreliable code). But in general, returning errors is supposed to be how all errors are signaled. I've always loved the semantic distinction between panics vs errors in go, they feel sooo much clearer than "normal" exception handling (try ... catch) in other languages which syntactically equivocate such common cases as "this file doesn't exist" with "the program is misbehaving due to physical RAM corruption". I think it's great that panic vs errors makes that a brighter line.
Assuming recover has to exist, I think forcing it to be in a deferred function is genius because it composes so well with how defers work in go. It's guaranteed to run "when the function returns" which is exactly the time to catch such truly catastrophic behaviors.
FTA: “In Go, empty structs occupy zero bytes. The Go runtime handles all zero-sized allocations, including empty structs, by returning a single, special memory address that takes up no space.
This is why they’re commonly used to signal on channels when you don’t actually have to send any data. Compare this to booleans, which still must occupy some space.”
I would expect the compiler to ensure that all references to true and false reference single addresses, too. So, at best, the difference of the more obscure code is to, maybe, gain 8 bytes. What do I overlook?
> I would expect the compiler to ensure that all references to true and false reference single addresses, too.
Why? If you're sending a constant true to a channel, wouldn't that true value exist in the stack frame for the function call? It seems like that would make more sense than a pointer to a constant true value being stored in the stack frame and dereferencing that every time you need the constant value.
> So, at best, the difference of the more obscure code is to, maybe, gain 8 bytes. What do I overlook?
Constructing channels in a loop would potentially multiply memory usage here
Ah the old nil values boxed into non-nil interfaces. Even after 8 years writing go code almost every day this still bites me occasionally. I've never seen code that actually uses this. I understand why it is the way it is but I hate it.
Be prepared to be called a newbie for criticising typed nils.
My post https://news.ycombinator.com/item?id=44982491 got a lot of hate from people who defend Go by saying "so just don't do that!", and people trying to explain my own blog post to me.
I ditched Go after an evaluation years ago. I can remember it was an issue with nil pointers being non-intuitive that turned me off. And exception handling. A pity because the runtime and ecosystem/community seemed pretty good.
Yes, that'a bit too late after ten+ years perhaps but I wished we had a nil type and checking whether the interface is empty was a type assertion.
In all other cases, like
any(2) == 2, we compare the values.
Then again that would mean that the nil identifier would be coerced into a typed nil and we would check for the nilness of what is inside an interface in any(somepointer) == nil.
wrt the current behavior, it also makes sense to have a nil value that remains untyped. But in many other cases we do have that automatic inference/coercion, for instance when we set a pointer to nil.(p = nil)
That's quite subtle and that ship has sailed though.
The advice I've read (and follow) is always to return values, not interfaces, from functions and test for nil against them. That IME tends to nip the majority of nil interface problems in the bud.
My opinion after using go professionally for ~2 years and repeatedly running into gotchas such as https://go.dev/blog/loopvar-preview is that it's just not a good language.
A lot of people praise it for it's "simplicity" and "explicitness" but frankly, even just figuring out whether something is being passed by reference or value is often complicated. If you're writing code where you never care about that, sure. But for any real project it's not actually better or simpler than C++ or Python.
One of the cooler things in Go these days is that the new function based iterators are based on coroutines, and you can use the iter.Pull function to abuse that :)
Problem with this is that it requires the whole string to be iterated over, byte by byte, or rune by rune, whereas len() does no such thing as the length is stored in the underlying type.
... even though the value is nil, the type of the variable is a non-nil interface... Go "boxes" that value in an interface, which is not nil. This can really bite you if you return interfaces from functions
Bit me when I was noob. These days, I fail build if ireturn fails.
go install github.com/butuzov/ireturn/cmd/ireturn@latestireturn ./...
Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily.
sync.WaitGroup(n) panics if Add(x) is called after a Done(-1) & n isn't now zero. Unsure if WaitGroups and easy belong in the same sentence. May be they do, but I'd rather reimplement Java's CountDownLatch & CyclicBarrier APIs in Go instead.
When you embed structs, you also implicitly promote any methods they contain ... Say, for instance, you embed a time.Time struct onto a JSON response field and try to marshal that parent ... Since the time.Time method has a MarshalJSON() method, the compiler will run that over the regular marshalling behavior
> Runes correspond to code points in Go, which are between 1 and 4 bytes long.
That's the dumbest thing I've read in this month. Why did they use the wrong word, sowing confusion¹, when any other programming language and the Unicode standard uses the correct expression "code point"?
Actually no, these are Unicode scalars, not code points; they exclude the surrogate category.
I agree that rune is a very poor name for it. It both mistakes what runes actually are and clashes with the runic block. But C# has adopted the Rune name for some reason.
Rust simply calls these char, and OCaml uchar (unicode char), which are much better choices.
acatton|4 months ago
> Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily. It takes the place of using the go keyword, [...]
99% of the time, you don't want to use sync.WaitGroup, but rather errgroup.Group. This is basically sync.WaitGroup with error handling. It also has optional context/cancellation support. See https://pkg.go.dev/golang.org/x/sync/errgroup
I know it's not part of the standard library, but it's part of the http://golang.org/x/ packages. TBH, golang.org/x/ is stuff that should be in the standard library but isn't, for some reason.
blixt|4 months ago
I discovered it after I had already written my own utility to do exactly the same thing, and the code was almost line for line the same, which was pretty funny. But it was a great opportunity to delete some code from the repo without having to refactor anything!
mholt|4 months ago
lagniappe|4 months ago
think of it as testing/staging before being merged into stable stdlib
infogulch|4 months ago
How does it cancel in-progress goroutines when the provided context is cancelled?
h4ck_th3_pl4n3t|4 months ago
With standard waitgroups I always move my states as a struct with something like a nested *data struct and an err property which is then pushed through the channel. But this way, my error handling is after the read instead of right at the Wait() call.
WhyNotHugo|4 months ago
mwsherman|4 months ago
A grapheme can be multiple codepoints, with modifiers, joiners, etc.
This is true in all languages, it’s a Unicode thing, not a Go thing. Shameless plug, here is a grapheme tokenizer for Go: https://github.com/clipperhouse/uax29/tree/master/graphemes
HeyImAlex|4 months ago
virtualritz|4 months ago
I do not use Go but ran into this when I had to write a Go wrapper for some Rust stuff the other day. I was baffled.
porridgeraisin|4 months ago
The part about changing a map while iterating is wrong though. The reason you may or may not get it is because go iterates in intentionally random order. It's nothing to do with speed. It's to prevent users from depending on the iteration order. It randomly chooses starting bucket and then goes in circular order, as well as randomly generates a perm of 0..7 inside each bucket. So if your edit goes into a bucket or a slot already visited then it won't be there.
Also, python is not an example to the contrary. Modifying python dicts while iterating is a `RuntimeError: dictionary changed size during iteration`
valzam|4 months ago
jbreckmckye|4 months ago
P1: The type and its method vtable
P2: The value
Once I understood that I could intuit how a nil Foo was not a nil Bar and not an untyped nil either
valzam|4 months ago
unknown|4 months ago
[deleted]
Groxx|4 months ago
No, it's just doing the usual "replace unprintable characters when printing" behavior. The data is unchanged, you have no guarantees of UTF-8 validity at all: https://go.dev/play/p/IpYjcMqtmP0
formerly_proven|4 months ago
In Python you'll actually get a RuntimeError here, because Python detects that you're modifying the dictionary while iterating over it.
DarkNova6|4 months ago
OvervCW|4 months ago
My experience developing in it always gave me the impression that the designers of the language looked at C and thought "all this is missing is garbage collection and then we'll have the perfect language".
I feel like a large amount of the feeling of productivity developers get from writing Go code originates from their sheer LOC output due to having to reproduce what other languages can do in just a few lines thanks to proper language & standard library features.
tptacek|4 months ago
tapirl|4 months ago
They are many real subtleties in Go, which even many professional Go programmers are not aware of. Here are some of them: https://go101.org/blog/2025-10-22-some-real-go-subtleties.ht...
NuclearPM|4 months ago
“for true {...} and for {...} are not eqivalent”
So what? The compiler will tell you the first time you try to run that “for true” abomination that it is invalid code.
voidUpdate|4 months ago
Is index-based string interpolation easier to follow? I would find it easier to understand a string interpolation when the variable name is right there, rather than having to count along the arguments to find the particular one it's referencing
furyofantares|4 months ago
I also found this very confusing:
> When updating a map inside of a loop there’s no guarantee that the update will be made during that iteration. The only guarantee is that by the time the loop finishes, the map contains your updates.
That's totally wrong, right? It makes it sound magical. There's a light explainer but I think it would be a lot more clear to say that of course the update is made immediately, but the "range" iterator may not see it.
jerf|4 months ago
It's all just spelling. Your compiler just turns
into anyhow. It's not a huge transform.I think people get bizarrely hung up on the tiny details of this between languages... but then, I think that extensive use of string interpolation is generally a code smell at best anyhow, so I'm probably off the beaten path in more than one way here.
lelandbatey|4 months ago
In Python, calling "{}".format(x) is string formatting, while string interpolation would be to use the language feature of "f-strings" such as f"{x}" to do the same thing. As far as I know, go doesn't have string interpolation, it only has convenient string formatting functions via the fmt package.
Basically, if you format strings with a language feature: interpolation. If you use a library to format strings: string formatting.
gethly|4 months ago
liampulles|4 months ago
The upshot of this dogmatism is that its comparatively easy to dev on long-lived Go projects. If I join a new team with an old Go project, there's a very good chance that I'll be able to load it up in my IDE and get all of Go's excellent LSP, debug, linting, testing, etc. tooling going immediately. And when I start reading the code, its likely not going to look very different from a new Go project I'd start up today.
(BTW Thanks OP for these subtleties, there were a few things I learned about).
jasonthorsness|4 months ago
The one thing I wish Go had more than anything is read-only slices (like C#).
The one thing I wish more other languages had that Go has is structural typing (anything with Foo() method can be used as an interface { Foo() }.
gethly|4 months ago
On the other hand, now that we have iterators in Go, you can create a wrapper for []byte that only allows reading, yet is iterable.
But then we're abstracting away, which is a no-go in Go and also creates problems later on when you get custom types with custom logic.
mwsherman|4 months ago
ReadOnlySpan<T> in C# is great! In my opinion, Go essentially designed in “span” from the start.
username223|4 months ago
Cthulhu_|4 months ago
An alternative is to introduce something like annotations, but I'm sure there will be resistance as it makes the language lean closer to e.g. Java.
But my take on that is that if you want stricter typing like that, you should actually go to Java or C# or whatever.
rowanseymour|4 months ago
gethly|4 months ago
callc|4 months ago
I was so surprised by the design choice to need to put recover in in deferred function calls. It’s crazy to smush together the error handling and normal execution code.
lelandbatey|4 months ago
Assuming recover has to exist, I think forcing it to be in a deferred function is genius because it composes so well with how defers work in go. It's guaranteed to run "when the function returns" which is exactly the time to catch such truly catastrophic behaviors.
gethly|4 months ago
func Foo() { try { maybePanic() } catch (err any) { doSomething(err) }
}vs
func Foo() { defer func() { if err := recover(); err != nil { doSomething(err) } }()
Someone|4 months ago
This is why they’re commonly used to signal on channels when you don’t actually have to send any data. Compare this to booleans, which still must occupy some space.”
I would expect the compiler to ensure that all references to true and false reference single addresses, too. So, at best, the difference of the more obscure code is to, maybe, gain 8 bytes. What do I overlook?
tczMUFlmoNk|4 months ago
If you have a buffered channel with 100 "struct{}{}"s in it, you only need to store the length, since the element type is zero-sized.
Zambyte|4 months ago
Why? If you're sending a constant true to a channel, wouldn't that true value exist in the stack frame for the function call? It seems like that would make more sense than a pointer to a constant true value being stored in the stack frame and dereferencing that every time you need the constant value.
> So, at best, the difference of the more obscure code is to, maybe, gain 8 bytes. What do I overlook?
Constructing channels in a loop would potentially multiply memory usage here
unknown|4 months ago
[deleted]
h4ck_th3_pl4n3t|4 months ago
That means it would work if *bool is possible but it's not.
foldr|4 months ago
arccy|4 months ago
rowanseymour|4 months ago
thomashabets2|4 months ago
My post https://news.ycombinator.com/item?id=44982491 got a lot of hate from people who defend Go by saying "so just don't do that!", and people trying to explain my own blog post to me.
amelius|4 months ago
aatd86|4 months ago
Then again that would mean that the nil identifier would be coerced into a typed nil and we would check for the nilness of what is inside an interface in any(somepointer) == nil.
wrt the current behavior, it also makes sense to have a nil value that remains untyped. But in many other cases we do have that automatic inference/coercion, for instance when we set a pointer to nil.(p = nil)
That's quite subtle and that ship has sailed though.
kitd|4 months ago
tonymet|4 months ago
It’s really elegant acquisition by reading, and releasing the semaphore by writing.
Great to limit your rest / http crawlers to 8 concurrent calls like a web browser.
sa46|4 months ago
One problem with using a channel as a semaphore is you need to track if you've closed the channel when "releasing".
https://pkg.go.dev/golang.org/x/sync/semaphore#Weighted.Acqu...
ball_of_lint|4 months ago
A lot of people praise it for it's "simplicity" and "explicitness" but frankly, even just figuring out whether something is being passed by reference or value is often complicated. If you're writing code where you never care about that, sure. But for any real project it's not actually better or simpler than C++ or Python.
the_mitsuhiko|4 months ago
johnmaguire|4 months ago
Try utf8.RuneCountInString().
gethly|4 months ago
ignoramous|4 months ago
go install github.com/butuzov/ireturn/cmd/ireturn@latest ireturn ./...
sync.WaitGroup(n) panics if Add(x) is called after a Done(-1) & n isn't now zero. Unsure if WaitGroups and easy belong in the same sentence. May be they do, but I'd rather reimplement Java's CountDownLatch & CyclicBarrier APIs in Go instead. #£@&+!dontlaugh|4 months ago
fweimer|4 months ago
Philip-J-Fry|4 months ago
They will get cleaned up.
ale42|4 months ago
unknown|4 months ago
[deleted]
bmn__|4 months ago
> Runes correspond to code points in Go, which are between 1 and 4 bytes long.
That's the dumbest thing I've read in this month. Why did they use the wrong word, sowing confusion¹, when any other programming language and the Unicode standard uses the correct expression "code point"?
¹ https://codepoints.net/runic already exists
debugnik|4 months ago
Actually no, these are Unicode scalars, not code points; they exclude the surrogate category.
I agree that rune is a very poor name for it. It both mistakes what runes actually are and clashes with the runic block. But C# has adopted the Rune name for some reason.
Rust simply calls these char, and OCaml uchar (unicode char), which are much better choices.
commandersaki|4 months ago