Installing Go, Modules and Project Layout: go.mod, Packages, internal/ and cmd/
A Go module is defined by go.mod: since Go 1.21 the go line is a binding minimum version, go.sum holds a hash for every dependency, and downloads are verified through proxy.golang.org and sum.golang.org. The story of the version go mod init writes, the toolchain line and GOTOOLCHAIN, staticcheck via the tool directive, exporting with uppercase names, the internal/ and cmd/ layout, go work, go mod tidy -diff and Go 1.27 merging require blocks into two.
Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the code also compiles with Go 1.26.11. staticcheck 2026.2.2 (module version v0.8.2) is used as a developer tool.
Guess First
In 01 we started the first Go service of Bookstore, the online bookstore used as the shared example across this site's series, with go mod init bookstore. The resulting go.mod had two lines:
module bookstore
go 1.27.4
Someone on the team still uses Go 1.26.11, and the company build server has GOTOOLCHAIN=local set, so Go downloads no other toolchain. What happens when this person pulls the project and runs go build ./...? The code uses nothing specific to 1.27; does it compile, does it warn, or does it not start at all? What would change if the same person left GOTOOLCHAIN at its default?
GOTOOLCHAIN=go1.26.11 go build ./...
go: go.mod requires go >= 1.27.4 (running go 1.26.11; GOTOOLCHAIN=go1.26.11)
The build never started. The go line is not a comment or a suggestion; since Go 1.21 it is a binding minimum version. Even though the code could compile on 1.26, go.mod asks for 1.27.4 and the toolchain refuses. With the default setting, Go downloads the required toolchain itself and builds with it. In this post we open every line of go.mod, go.sum and the module proxy, and move the single-file service into a real project layout with cmd/ and internal/.
Installing Go
Go's official distribution lives at go.dev/dl. On Windows you download and run the MSI installer; it puts Go under C:\Program Files\Go and adds the go command to PATH. If you prefer, you can extract the zip archive from the same page into a folder of your choice and add its bin folder to PATH yourself; while preparing these posts I used the archive and compared the hash of the downloaded file with the SHA256 value on the page. The installation does not ask for an SDK and a runtime separately, because in Go the runtime goes into every binary.
After installation there are two folders to know. The first is Go itself (GOROOT). The second is GOPATH, which defaults to %USERPROFILE%\go on Windows. Downloaded modules go into the module cache under GOPATH\pkg\mod, and tools you install with go install go under GOPATH\bin. The module cache does the job NuGet's %USERPROFILE%\.nuget\packages folder does. Older Go articles may tell you that projects must live under GOPATH\src; that rule went away with modules, and your project can live anywhere on disk.
go.mod Line by Line
The module bookstore line defines the module path. This path is the prefix of the import paths of the packages inside the module: we will import the package in the internal/catalog folder as bookstore/internal/catalog. If you are writing a library that others will download with go get, the path should point to where the code lives, for example github.com/user/bookstore. For an application like ours that no other module imports, a short name is enough. Think of the assembly name of the Bookstore.Api project on the C# side; the difference is that in Go this name shows up in import paths everywhere.
The go line is the lowest Go version the module requires. As we saw in Guess First, it is binding since Go 1.21: an older toolchain refuses to build the module. The same line also selects the language version. For example, the loop variable behaviour that changed in 1.22 applies to modules whose go line is 1.22 or higher; we will see this in 03. It is like C#'s LangVersion and TargetFramework merged into one line.
What go mod init writes on this line has a small history. Go 1.26.0 started writing the previous release (go 1.25.0) into new modules. The goal was for newly created projects to work on both supported releases. But the change drew complaints and was reverted in 1.26.1 (Go issue #77653), while the 1.26 release notes still describe the original behaviour. Today 1.27.4 also writes its own version, which is why 01 got the go 1.27.4 line. Since we write the series to compile on 1.26 as well, we lower the line ourselves:
go mod edit -go=1.26.0
module bookstore
go 1.26.0
The toolchain line, on the other hand, is a suggestion: "use this toolchain when developing this module". It only takes effect when the module you build is the main module and your toolchain is older than the suggested one. The GOTOOLCHAIN environment variable controls this download behaviour. Its default is written in the go.env file in Go's own folder:
# Automatically download newer toolchains as directed by go.mod files.
# See https://go.dev/doc/toolchain for details.
GOTOOLCHAIN=auto
In a small module with the lines go 1.26.0 and toolchain go1.27.4, I ran Go 1.26.11's go command with two settings:
GOTOOLCHAIN=auto go env GOVERSION
GOTOOLCHAIN=local go env GOVERSION
go: downloading go1.27.4 (<goos>/<goarch>)
go1.27.4
go1.26.11
With auto, 1.26.11 saw the toolchain line, downloaded 1.27.4 and ran the command with it. The parentheses show your platform, windows/amd64 on Windows x64. With local it carried on with its own version; it already satisfied go 1.26.0. The downloaded toolchain goes into the module cache like any other module and is verified against the checksum database. We do not use a toolchain line in Bookstore; choosing the Go version explicitly in the CI pipeline is clearer.
go.sum, the Module Proxy and the Checksum Database
Let's add the first dependency to the project. The application code only uses the standard library for now, so the first dependency will be a developer tool: staticcheck, the most widely used static analysis tool in the Go community. Since Go 1.24, tools can be added to go.mod with a tool line; it is the counterpart of local tools in .NET's dotnet-tools.json.
go get -tool honnef.co/go/tools/cmd/staticcheck@v0.8.2
go: downloading honnef.co/go/tools v0.8.2
go: downloading golang.org/x/tools v0.51.0
go: downloading github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c
go: downloading golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678
go: downloading golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
go: downloading golang.org/x/sync v0.23.0
go: downloading golang.org/x/mod v0.41.0
go: added github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c
go: added golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678
go: added golang.org/x/mod v0.41.0
go: added golang.org/x/sync v0.23.0
go: added golang.org/x/tools v0.51.0
go: added honnef.co/go/tools v0.8.2
module bookstore
go 1.26.0
tool honnef.co/go/tools/cmd/staticcheck
require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/tools v0.51.0 // indirect
honnef.co/go/tools v0.8.2 // indirect
)
The require block states exactly which modules and versions the build will use. // indirect says our code does not import that module directly; staticcheck also counts as indirect because the application code does not import it. Long versions such as v1.4.1-0.20240526193622-a339e1f7089c are pseudo-versions: versions given to an untagged commit, containing its date and commit hash. Now everyone runs the tool with the same version:
go tool staticcheck -version
go tool staticcheck ./...
staticcheck 2026.2.2 (0.8.2)
The second command printed nothing, so there are no findings. The same command also created a go.sum file. Two lines from it:
honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
There are two hashes per module version: one for all of the module's files, one for its go.mod file only. When Go downloads a module, it computes the hash and compares it with the value in go.sum; if they differ, the build stops. go.sum is not a lock file, since go.mod already states the versions exactly. Its job is to guarantee that the same version is byte-for-byte the same content on every machine. That is why both are committed.
Where the hash comes from the first time is answered by the checksum database. By default, modules are not downloaded straight from GitHub but from the module proxy run by Google. The hashes are kept in a log that everyone sees identically and that cannot be altered afterwards:
go env GOPROXY GOSUMDB
https://proxy.golang.org,direct
sum.golang.org
Even if someone deletes a tag on GitHub and publishes different code under the same name, the proxy keeps serving the content it saw first, and the checksum database does not accept a different hash. In NuGet terms: the require lines in go.mod correspond to PackageReferences, the proxy to nuget.org, and go.sum to the content hashes in packages.lock.json. For private modules inside your company, the GOPRIVATE setting tells Go not to ask the proxy and the checksum database about them. You check that the files in the module cache have not changed like this:
go mod verify
all modules verified
One last difference: Go picks the version used in the build from the minimum requirements in the go.mod files. The algorithm is called MVS, Minimal Version Selection, and it selects the highest of the minimum versions requested for each module. A new release of a dependency does not change your build; the version only changes when you upgrade it with go get.
Package = Folder, Uppercase = Exported
Now let's split the single-file service. In Go a folder is a package; all .go files in a folder carry the same package name. We move the books into the internal/catalog package. This package is the counterpart of the Bookstore.Catalog namespace in C#:
// Package catalog holds the books Bookstore sells.
package catalog
// Book is one title in the catalog.
type Book struct {
ISBN string `json:"isbn"`
Title string `json:"title"`
}
// books is unexported: other packages can only reach it through Store.
var books = []Book{
{ISBN: "9780441172719", Title: "Dune"},
{ISBN: "9780547928227", Title: "The Hobbit"},
{ISBN: "9780141439549", Title: "Middlemarch"},
}
// Store serves books from memory for now.
type Store struct{}
// All returns every book in the catalog.
func (Store) All() []Book {
return books
}
Book, Store and All start with an uppercase letter, so they are exported; C#'s public. books starts with a lowercase letter and is only visible inside the catalog package. In C#, internal access stopped at the assembly; in Go, the boundary of lowercase names is the package. Starting a comment with the name itself is also a Go rule: go doc and linters expect that form. main.go now lives in the cmd/bookstore-api folder and gets its books from catalog:
// Command bookstore-api serves the Bookstore HTTP API.
package main
import (
"encoding/json"
"log"
"net/http"
"bookstore/internal/catalog"
)
func main() {
var store catalog.Store
http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Bookstore API\n"))
})
http.HandleFunc("GET /api/books", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
addr := "127.0.0.1:52710"
log.Printf("bookstore-api listening on http://%s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
The line var store catalog.Store calls no constructor. Store is an empty struct and its zero value is ready to use; we will come back to zero values in 03. The import groups follow the order gofmt likes: standard library first, a blank line, then packages from the module. Let's deliberately try to reach the lowercase books from outside, writing catalog.books instead of store.All():
go build ./...
# bookstore/cmd/bookstore-api
cmd/bookstore-api/main.go:13:6: declared and not used: store
cmd/bookstore-api/main.go:20:37: name books not exported by package catalog
We got two errors. The second is the visibility error we expected. The first is the rule we discussed in 01: the store variable is no longer used, so the compiler counted it as an error too.
internal/ and cmd/: A Plain Layout Following the Official Guide
The new shape of the project:
bookstore/
go.mod
go.sum
cmd/
bookstore-api/
main.go
internal/
catalog/
catalog.go
This is the layout the "Organizing a Go module" guide on go.dev recommends for server projects. Executable programs live under cmd/, each in its own folder; if we add a data import tool later, it becomes cmd/bookstore-import. Server logic goes under internal/. The name internal is special to Go: packages below a folder with this name can only be imported by code in the tree rooted at the parent of internal. So everyone in the bookstore module can use bookstore/internal/catalog, and no other module can. It is like C#'s internal access modifier at the level of the package tree, except that the go command enforces it rather than the compiler.
Search the web for "Go standard project layout" and one of the first results is the golang-standards/project-layout repository on GitHub. Despite its name, it is not an official standard; its README says so plainly, and in 2021 Russ Cox of the Go team opened an issue on the repository titled "this is not a standard Go project layout". Most of its folders, such as pkg/, api/, web/ and build/, are unnecessary for a small service. The official guide is plainer: open folders as the need arises. In this series we will proceed with cmd/bookstore-api, internal/catalog and the internal/orders we add in 04.
go work: Working on Several Modules at Once
Sometimes you develop two modules at the same time, such as a library and an application that uses it. Instead of publishing the library after every change, since Go 1.18 you can open a workspace file. Let's try the internal rule this way. Next to the bookstore folder I created a second module named example.com/reports and added an import of bookstore/internal/catalog to its main.go:
go work init ./bookstore ./reports
cat go.work
cd reports && go build ./...
go 1.27.4
use (
./bookstore
./reports
)
package example.com/reports
main.go:6:2: use of internal package bookstore/internal/catalog not allowed
Thanks to the workspace, the reports module sees bookstore from the local folder, but the internal rule still applies. If you want to share catalog with other modules, you have to move it out of internal, which means you must keep that package's API backward compatible from then on. go.work is usually not committed; it is a local development convenience. Think of it as a temporary counterpart of adding two projects to the same solution in Visual Studio and giving one a ProjectReference to the other.
Updating Dependencies and go mod tidy
The everyday commands are few: go get example.com/mod@v1.2.3 adds a dependency or moves it to a specific version, go get -u ./... upgrades the dependencies the code uses, go get example.com/mod@none removes one, and go mod tidy fixes go.mod and go.sum to match the code: it adds what is missing and removes what is unused. After adding staticcheck I asked with -diff whether go.sum was tidy. This flag does not change the files; it only prints the difference and returns a non-zero exit code when there is one (output shortened):
go mod tidy -diff
diff current/go.sum tidy/go.sum
--- current/go.sum
+++ tidy/go.sum
-golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
go get had written only what the build needs; tidy also adds the hashes needed for the dependencies' own tests and removes one unused line. After running go mod tidy and committing the files, the same command printed nothing and returned 0. It makes a good CI check: if someone forgets to update go.mod, the pipeline stops.
Go 1.27 added a small but useful behaviour to tidy. Over time several require blocks pile up in go.mod: lines added by hand, blocks opened by different tools. In modules whose go line is 1.27 or higher, tidy gathers them into at most two blocks: one for direct and one for indirect dependencies. I tried it with a messy go.mod:
module example.com/tidydemo
go 1.27.0
require github.com/go-chi/chi/v5 v5.5.0
require golang.org/x/mod v0.41.0 // indirect
require (
// code analysis helpers
golang.org/x/tools v0.51.0
)
require golang.org/x/sync v0.23.0 // indirect
go mod tidy
cat go.mod
module example.com/tidydemo
go 1.27.0
require (
github.com/go-chi/chi/v5 v5.5.0
// code analysis helpers
golang.org/x/tools v0.51.0
)
require (
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
)
Four blocks became two, and the comment attached to the dependency was kept. When I tried the same file with go 1.26.0, 1.27.4's tidy left the four blocks in place; it only collapsed the single-entry parenthesised block into one line, with its comment above. So this behaviour depends not on the toolchain version but on the module's go line. Since Bookstore stays at go 1.26.0, it does not apply to us; we have a single require block anyway.
In Production
Always commit go.mod and go.sum together, and run go mod tidy -diff and go mod verify in CI. Build commands do not modify go.mod by default; if a line is missing, they fail instead of quietly adding it. That is good behaviour: the dependency list should not change during a build.
If you have company-internal modules, set GOPRIVATE. Without it, Go tries to ask the public proxy and checksum database about your private repositories; that both fails and leaks module names. If your corporate network uses an internal proxy such as Athens or Artifactory, point GOPROXY at it, just as you would use an internal NuGet feed.
Choose the Go version in CI deliberately. The GOTOOLCHAIN=auto default quietly downloads a toolchain in the pipeline if go.mod asks for a newer release. That usually works, but if you want to see in the logs which version did the build, install the version explicitly in the pipeline and use GOTOOLCHAIN=local.
Common Pitfalls
Putting everything into one package called utils or common. In Go the package name appears in every call: utils.FormatPrice tells the reader nothing, catalog.FormatPrice does. Put the function in the package of the domain that uses it; if something is genuinely shared, give it a name that says what it does, such as money or isbn.
Import cycles. In C#, two namespaces in the same assembly can use each other freely. In Go, cycles between packages are forbidden. In a small module where catalog imports orders and orders imports catalog:
go build ./...
package example.com/cyc/catalog
imports example.com/cyc/orders from catalog.go
imports example.com/cyc/catalog from orders.go: import cycle not allowed
The fix is usually to correct the direction of the dependency: an order knows about books, a book does not know about orders. If both sides need the same type, either move that type into a small third package or define a small interface on one side; we will see the second option in 04.
Building deep folder hierarchies. Paths like internal/domain/models/entities/book make import lines long and package names meaningless in Go. Go packages are flat and few.
Committing go.work. The workspace file describes the folder layout on your machine. If you commit it, CI or a teammate's machine will look for folders that do not exist there.
A Small Experiment
★☆☆ Add an exported method Count() int to the catalog package and call it from main.go. Then rename it to count and see what the compiler says.
★★☆ Add a second command to the project called cmd/bookstore-import; it should only print the number of books in catalog.Store{}.All(). Build both together with go build -o bin/ ./.... How many files appeared in the bin folder?
★★★ In an empty folder, create a module with a go 1.27.0 line, write a small main.go that imports chi, run go get github.com/go-chi/chi/v5@v5.5.0 and add a second require block to go.mod by hand. Count the blocks after go mod tidy. Then change the go line to 1.26.0 and repeat.
The project skeleton is ready. In the next post we move on to the language we will fill it with: variables, zero values, the difference between strings and runes, slices and arrays, maps, pointers and Go's only loop, for. As the example, we will filter the books in the catalog by price.