When we had graded enough Go PRs to start looking for patterns, we expected to find what most Go learning resources emphasize: syntax errors, missing type assertions, incorrect use of defer. We found those. What we didn't expect was how predictable the harder patterns were, and how clearly they clustered around concurrency.
The summary version: syntax and basic type errors resolve fast, usually within the first five PRs. Concurrency misconceptions persist. Some developers are still exhibiting the same goroutine pattern misunderstandings on their fortieth PR that they had on their fifth, even when the syntax is now clean.
The first phase: surface errors
The first ten to fifteen PRs for a developer new to Go show a predictable set of surface errors. These are easy to catalog: := in a scope where the variable already exists, incorrect slice initialization, missing error checks, struct field naming that doesn't follow Go conventions.
These errors share a characteristic: they're visible in isolation. You can look at a line, know it's wrong, and fix it without understanding anything deeper about the surrounding code. Linters catch most of them. Code review catches the rest. They decline sharply as the developer writes more code, regardless of whether feedback is targeted or generic.
Developers from Python clear these faster than developers from Java, probably because Python's syntax is closer to Go in some surface respects. Developers from statically typed languages like Java or C# clear the type-related surface errors faster because they already have a mental model for type systems, even if Go's is different from what they know.
The second phase: idiom errors
Idiom errors are more interesting. These are patterns that compile and run correctly but don't look like Go to an experienced Go developer. Error propagation without added context. Interfaces defined at the implementation site rather than the consumption site. Struct methods with overly long receiver names. Function signatures that take concrete types where interfaces would be more appropriate.
These errors don't surface in automated checks. They surface in code review, and only if the reviewer knows Go well enough to identify them and cares enough to explain. In teams with experienced Go developers who have time for thorough reviews, idiom errors get caught. In teams where Go is newer or reviews are quick, they accumulate into codebases that experienced Go developers find uncomfortable.
Idiom errors decline more slowly than surface errors, typically over twenty to thirty PRs, and they require targeted feedback to decline at all. Developers who don't receive feedback on idiom errors don't naturally converge on idiomatic patterns. They converge on patterns that work, which is not the same thing.
The concurrency layer
Concurrency is where the learning curve becomes genuinely nonlinear. The surface patterns for goroutines and channels are learnable quickly: you can read the spec, understand the mechanics, and write goroutines that do what you intend. What's harder is the mental model of goroutine lifecycle and ownership that sits beneath the mechanics.
The most persistent pattern we see is goroutines launched inside loops or conditionals without a clear accounting of when they will complete. This isn't always a goroutine leak in the technical sense. Often the goroutines do finish. But the code doesn't make the lifecycle explicit, and in production under different load conditions or with modified business logic, the lack of explicit lifecycle management produces real leaks.
Developers who have written asyncio Python or Java concurrent code bring a mental model of task lifecycle that maps imprecisely to Go's goroutines. In Python's asyncio, tasks are tracked by the event loop. In Java, thread pools track thread lifecycle. In Go, nothing tracks goroutine lifecycle automatically. You track it yourself, with WaitGroups, contexts, or channel patterns. If you don't, and something goes wrong, there's no automatic accountability mechanism to surface the problem.
This distinction takes a long time to internalize, because the situations where it matters are not the situations where you're testing. Tests usually complete, goroutines usually finish, the problem doesn't show up until something is under load or interrupted at an unusual point.
Channel semantics under pressure
A related persistence category is channel buffering semantics. A developer who understands that buffered channels don't block on send until the buffer is full can still write code that depends on that buffering semantics in a way that introduces subtle ordering assumptions. An unbuffered channel becomes a synchronization point. A buffered channel of size 1 is not the same as an unbuffered channel from a synchronization standpoint, even though it looks almost the same.
The PRs where this shows up most often are the ones where a developer is using channels to signal completion between goroutines. The channel is used correctly for the happy path but doesn't handle the case where the sending goroutine exits before the receiving goroutine gets to the receive. In tests, this is invisible. In production under unusual termination conditions, it produces hangs.
These aren't beginner errors in the sense of "this developer is new to programming." They're conceptual gaps specific to Go's concurrency model that require real Go production experience or careful targeted instruction to close.
What 500 PRs does not tell you
We're careful about what conclusions to draw from this set. These are developers who were learning intentionally, submitting practice PRs specifically to get feedback. That's a different population from developers who are learning on the job through code review. The feedback they received was more systematic than code review feedback, so the rate at which concepts resolved may not generalize to other settings.
What we believe does generalize is the pattern: syntax and surface idioms resolve fast and with limited targeted feedback, while concurrency mental model issues require significantly more repetitions and more specific feedback to resolve. That split appears consistent across prior language backgrounds, experience levels, and learning styles within our data.
The implication is that a Go learning program that doesn't specifically target concurrency concepts, and doesn't track whether those concepts are actually resolving rather than just being mentioned, is likely to produce developers who write syntactically correct Go that has latent concurrency issues. That's a real problem in production, and it's one that more surface-level feedback mechanisms don't reliably catch.