- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + the Hexagonal book above
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Search "Go project layout" on Google. The first result is golang-standards/project-layout. Fifty-five thousand stars. The org name has "standards" in it. The README opens with "Standard Go Project Layout."
It is not a standard. The Go team has never endorsed it. In 2021, Russ Cox, then Go's tech lead and a Go senior engineer at Google, opened an issue on the repo to say so directly.
The issue was eventually closed without action, but it still attracts comments years later. The repo is still on top of Google. New developers still copy the layout and fill their projects with empty directories named pkg/, api/, build/ci/, init/, deployments/, third_party/. The cargo cult is real, and it has a citation.
The repository, the org name, and the SEO problem
The repo lives at github.com/golang-standards/project-layout. Created in 2017 by a single user, kcq. The README's first non-title line is a disclaimer in bold:
NOT an official standard defined by the core Go dev team
Then it lists nineteen directories: /cmd, /internal, /pkg, /vendor, /api, /web, /configs, /init, /scripts, /build, /deployments, /test, /docs, /tools, /examples, /third_party, /githooks, /assets, /website. Most projects need three at most. Many need zero.
The disclaimer does not show up in Google's snippet. The org name does. New developers see "golang-standards", see fifty thousand stars, and conclude this is what the community agreed on. It is one person's preference, frozen in 2017, mistaken for a community decision because the org name was a marketing choice.
Issue #117: Russ Cox files the takedown
On April 9, 2021, Russ Cox opened issue #117, titled "this is not a standard Go project layout". He opened with:
The README makes clear that this is not official, but even the claim "it is a set of common historical and emerging project layout patterns in the Go ecosystem" is not accurate.
For example, the vast majority of packages in the Go ecosystem do not put the importable packages in a
pkgsubdirectory. More generally what is described here is just very complex, and Go repos tend to be much simpler.It is unfortunate that this is being put forth as "golang-standards" when it really is not. I'm commenting here because I am starting to see people say things like "you are not using the standard Go project layout" and linking to this repo.
That is the Go tech lead, on the public issue tracker, saying the framing is wrong. Three weeks later he posted a follow-up. After the maintainer asked him for an alternative, he wrote:
But for the record, the minimal standard layout for an importable Go repo is really:
- Put a LICENSE file in your root
- Put a go.mod file in your root
- Put Go code in your repo, in the root or organized into a directory tree as you see fit
That's it. That's the "standard".
He then listed eighteen of the layout repo's "rules": cmd/, pkg/, web/, api/, configs/, init/, scripts/, build/package/, build/ci/, deployments/, test/, docs/, tools/, examples/, third_party/, githooks/, assets/, website/. He prefixed each with "It is not required" and closed with one sentence: "The importable golang.org/x repos break every one of these 'rules'."
The golang.org/x repos are the official extended Go libraries (crypto, net, tools, text, sync), maintained by the Go core team. None of them follow the layout repo's structure. The repo's "standard" is not how the Go team writes Go.
Former Go team member Jaana Dogan (rakyll) chimed in on the same thread:
What @rsc describes here is a problem I observed many times. I saw many projects trying to "fix" their layout based on what's provided as a reference here. […]
A sitting Go tech lead and a former Go team member, in agreement, asked the maintainer to rename the org or archive the repo. Neither happened. The issue was closed without a rename, and the repo is still called golang-standards.
Issue #185: the rename request
A year later, on July 1, 2022, another user opened issue #185, titled "This organization should be renamed with a note in the README". The opening message references issue #117 directly:
This repository represents your opinion, @kcq, and solely your opinion. To represent this as in any way standard, best practices, recommended, or anything else that would make it seem like more than just your preference is misleading and harmful to the entire Go community […].
The maintainer's response:
The repo is not presented as an official standard from the Go team in any way and there's an explicit disclaimer providing sufficient context […].
The disclaimer exists. It is not enough. The org name is still golang-standards. Google still ranks it first. New Go developers find it before they find Russ Cox's commentary.
The wider reaction has been similar. Anton Stöckl, on his Substack, put it bluntly: "I've seen way too many Go applications with folder structures that look like they were generated by a cargo cult priest." The Hacker News thread on issue #117 hit the front page; the top comments described the same pattern, where someone migrating from another language applies the full template to a 200-line CLI and ends up with twelve empty directories.
The pkg/ directory: filler with no semantic meaning
Of all the directories in the layout repo, pkg/ is the one Russ Cox singled out: "the vast majority of packages in the Go ecosystem do not put the importable packages in a pkg subdirectory."
He is right. The standard library at github.com/golang/go/src has no pkg/; the packages live at the top level (net, os, io, strings, sync). golang.org/x/tools does not have one either. Across popular Go projects (cobra, viper, chi, gin), none put importable code in pkg/.
The pkg/ convention came from C and from Google's internal Go layout, where it had a specific meaning tied to a specific build system. In a public Go module, it has no meaning. The Go toolchain does not know what pkg/ is, does not enforce visibility, and does not filter imports. It is a three-letter directory name.
What does it do? It adds an import-path segment. github.com/you/myapp/users becomes github.com/you/myapp/pkg/users, three characters longer for nothing in return.
The cargo-cult version goes further: both pkg/ and internal/, plus eight other empty directories from the layout repo:
myapp/
cmd/api/main.go
pkg/users/users.go
internal/db/postgres.go
api/openapi.yaml
build/ci/.github/
configs/
deployments/
scripts/
tools/
third_party/
githooks/
assets/
test/
docs/
Half of these are empty. A quarter contain one file each. The fix: delete pkg/. Move the packages to the root, or to internal/ if they should be private. Re-run go build. Nothing breaks. Everything is shorter.
What cmd/ is actually for
cmd/ has a real purpose, but it is narrower than most people think. The official Go module layout doc:
A common convention is placing all commands in a repository into a
cmddirectory; while this isn't strictly necessary in a repository that consists only of commands, it's very useful in a mixed repository that has both commands and importable packages […].
The word that matters is mixed. If your repo has one binary, you do not need cmd/. Put main.go at the root. The Go toolchain is happy.
cmd/ earns its place when you have multiple binaries, or when you have one binary plus importable packages and want a clean separation:
myapp/
go.mod
cmd/
api/main.go // HTTP server
worker/main.go // queue consumer
migrate/main.go // runs DB migrations
internal/
users/users.go
orders/orders.go
Three binaries, one shared business-logic tree. Each main.go is small (wiring code only) and pulls real logic from internal/. If you only have one binary, this is overhead. main.go at the root and a flat package layout is shorter and refactors fine when a second binary appears later.
What internal/ is actually for
internal/ is the one directory in the layout repo that the Go toolchain actually understands. From the Go module layout doc:
Initially, it's recommended placing such packages into a directory named
internal; this prevents other modules from depending on packages we don't necessarily want to expose […].
The compiler enforces this. Code under internal/ can only be imported by code rooted at the same parent. Try to import it from another module and go build rejects you. This is not a convention; it is a rule encoded in the toolchain since Go 1.4.
That makes internal/ useful in two scenarios: you publish a library and want a clear "private API" boundary, or you write a service and want to signal "this is implementation detail, do not depend on it from outside." For a one-person CLI it is theatre. For a library or a multi-team service it is real.
The honest layout, by domain
Here is what a service looks like when you ignore the layout repo and follow what the Go team and the standard library suggest. This is a hexagonal-slice layout, stripped to the bones. Russ Cox would recognise it as "Go code in your repo, organised into a directory tree as you see fit."
welcomer/
go.mod
go.sum
LICENSE
cmd/
api/main.go
worker/main.go
internal/
user/
user.go // domain type + interfaces
service.go // application logic
postgres.go // adapter — DB
http.go // adapter — HTTP handlers
email/
email.go
sender.go
smtp.go
platform/
config/config.go
database/postgres.go
Notice what is missing: pkg/, api/, build/, configs/, deployments/, init/, scripts/, test/, tools/, examples/, third_party/, githooks/, assets/, website/. The Dockerfile sits at the root. CI lives in .github/workflows/. Tests live next to the code: user_test.go next to user.go.
The structure inside internal/user/ is what matters. Each subdirectory is a domain (user, email) instead of a layer (models, handlers, repositories). The domain owns its types, its application logic, and its adapters in one place:
// internal/user/user.go
package user
import "context"
type User struct {
ID string
Email string
Name string
}
type Repository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, u *User) error
}
type Mailer interface {
SendWelcome(ctx context.Context, u *User) error
}
// internal/user/service.go
package user
import "context"
type Service struct {
repo Repository
mailer Mailer
}
func NewService(r Repository, m Mailer) *Service {
return &Service{repo: r, mailer: m}
}
func (s *Service) Register(ctx context.Context, email, name string) (*User, error) {
u := &User{Email: email, Name: name}
if err := s.repo.Save(ctx, u); err != nil {
return nil, err
}
return u, s.mailer.SendWelcome(ctx, u)
}
The HTTP adapter and the Postgres adapter sit in the same package, implementing the interfaces from user.go. cmd/api/main.go wires them together in thirty to fifty lines of constructor calls. That is the whole shape.
This layout has a property the layer-based one does not: deleting a feature is a rm -rf internal/user/. You get exactly the import errors you expect, nowhere else. The blast radius matches the conceptual boundary. With models/ plus handlers/ plus services/ plus repositories/, deleting a feature means touching four directories and grepping for the name across all of them.
When you can break the rules
This is not dogma. Russ Cox's own answer to "what is the standard?" was "organized into a directory tree as you see fit." A handful of the layout repo's directories do earn their keep, in narrow circumstances.
cmd/ belongs in repos with multiple binaries; skip it for one. internal/ is worth it when you publish a library or want a privacy boundary inside a service, and overkill for a single-file CLI. pkg/ you can skip every time, since the standard library does not have one. api/ is useful if you generate Go code from OpenAPI or protobuf, and noise otherwise. The deployment-adjacent trio earns its place too:
-
build/,deployments/,configs/: only when there are enough files of each kind to make the root noisy. -
test/: never for unit tests. Tests live next to the code. Sometimes used for integration fixtures, though even then it is overkill for most projects.
Starting a new Go project? Put main.go and go.mod at the root. Move packages into internal/ once you have more than two of them, and only introduce cmd/ when a second binary appears. That is the entire decision tree.
Where the threads landed
Issue #117 was closed in August 2023. Issue #185 closed the same day. Issue #52, the original rename request that predated Russ Cox's involvement, was closed too. None of the closes came with a rename, an archive, or a README rewrite. The repo is still live, the org is still called golang-standards, Google still ranks it first, and the threads keep attracting comments.
This will not change unless GitHub steps in (unlikely, since there is no policy violation) or the maintainer decides to rename the org (unlikely after seven years of refusal). The repo will keep getting stars. New Go developers will keep cargo-culting the layout. The next person who joins your team will, statistically, have copied a pkg/ directory at some point.
The job is to know the history. When someone in a code review says "we should follow the standard Go project layout," point at issue #117. When they say "pkg/ is the convention," quote Russ Cox. When they say "but there are 55,000 stars," point at the standard library and ask which directory net/http lives in. Next time someone says "standard layout," link issue #117 and move on.
For the long-form version of this layout, including how hexagonal slicing plays out across cmd/, internal/, and the boundaries between domain, application, and adapter code with runnable examples, I cover it in Hexagonal Architecture in Go. It is the second book in the Thinking in Go series; the first (The Complete Guide to Go Programming) covers the language itself.
If you write a lot of Go alongside Claude Code or other AI coding tools, Hermes IDE is the editor I am building specifically for that workflow.

Top comments (0)