top | item 45565793

Go subtleties

252 points| darccio | 4 months ago |harrisoncramer.me

185 comments

order

acatton|4 months ago

> The wg.Go Function

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

mholt|4 months ago

The extended standard lib is pretty great, but definitely can't keep the Go compatibility promise, so it's good that it's separate.

lagniappe|4 months ago

>golang.org/x/ is stuff that should be in the standard library but isn't, for some reason

think of it as testing/staging before being merged into stable stdlib

infogulch|4 months ago

Wow how did I not know of this?!

How does it cancel in-progress goroutines when the provided context is cancelled?

h4ck_th3_pl4n3t|4 months ago

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.

WhyNotHugo|4 months ago

Looks like this isn’t usable with functions that take parameters. For that, you need wg.Add.

mwsherman|4 months ago

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.

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

virtualritz|4 months ago

len() is also returning int instead of uint/uint64 in Go.

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

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`

valzam|4 months ago

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.

jbreckmckye|4 months ago

As a Go learner, the best explanation that has made sense to me is that interface types essentially just compose two pointers:

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

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.

Groxx|4 months ago

>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

formerly_proven|4 months ago

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

DarkNova6|4 months ago

As somebody who only views Go from a distance, I see this list as a combination of „what‘s the big deal?“ and „please don‘t“.

OvervCW|4 months ago

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.

tptacek|4 months ago

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!

tapirl|4 months ago

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.

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

The examples in your link don’t seem to be very useful compared to the subject of this post.

“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

> 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

furyofantares|4 months ago

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.

jerf|4 months ago

"Am I out of date and we just call that string interpolation these days?"

It's all just spelling. Your compiler just turns

    x = "I want ${number/12|round} dozen eggs, ${name|namecase}"
into

    x = StrCon("I want ", round(number/12), " dozen eggs, ", namecase(name))
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

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.

gethly|4 months ago

Mutating maps during iteration is a big red flag.

liampulles|4 months ago

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

jasonthorsness|4 months ago

Great list! Reminds me to check out more of the new stuff in 1.25.

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

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.

mwsherman|4 months ago

In Go, string effectively serves as a read-only slice, if we are talking about bytes.

ReadOnlySpan<T> in C# is great! In my opinion, Go essentially designed in “span” from the start.

username223|4 months ago

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.

Cthulhu_|4 months ago

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.

rowanseymour|4 months ago

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"`

gethly|4 months ago

Annotations have been part of programming for ages. This is nothing new or out of the ordinary functionality.

callc|4 months ago

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.

lelandbatey|4 months ago

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.

gethly|4 months ago

Semantics. Go at least does not restrict you to wrap every single panicky call.

func Foo() { try { maybePanic() } catch (err any) { doSomething(err) }

  .. more code
}

vs

func Foo() { defer func() { if err := recover(); err != nil { doSomething(err) } }()

  maybePanic()

  .. more code
}

Someone|4 months ago

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?

tczMUFlmoNk|4 months ago

If you have a buffered channel with 100 "true"s in it, you're using 100 bytes.

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

> 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

h4ck_th3_pl4n3t|4 months ago

Go is copy by default.

That means it would work if *bool is possible but it's not.

foldr|4 months ago

That wouldn’t work because boolean variables can be mutated (whereas you can’t mutate a zero-sized value).

arccy|4 months ago

it's also so that there's no confusion about what the value represents.

rowanseymour|4 months ago

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.

amelius|4 months ago

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.

aatd86|4 months ago

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.

kitd|4 months ago

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.

tonymet|4 months ago

my favorite go trick is a simple semaphore using make(chan struct{}, CONCURRENCY) to throttle REST api calls and other concurrent goroutines.

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.

ball_of_lint|4 months ago

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.

the_mitsuhiko|4 months ago

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

johnmaguire|4 months ago

> Using len() with Strings, and UTF-8 Gotchas

Try utf8.RuneCountInString().

gethly|4 months ago

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.

ignoramous|4 months ago

  The time.After function creates a channel that will be sent a message after x seconds.
Or, will it? https://github.com/golang/go/issues/24595

  ... 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@latest ireturn ./...

  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
#£@&+!

ale42|4 months ago

Did anyone else read "Go subtitles" instead of the actual title?

bmn__|4 months ago

FTA:

> 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

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

commandersaki|4 months ago

Seeing as two of the authors designed utf8 (or at least concurrent to others), I think it’s safe to defer to their expertise and nomenclature here.