1. Why understand go build if the IDE has a Run button?
When you’re just starting out, an IDE feels like a life preserver: you press the green button — and the program “somehow” works. But as soon as a project becomes bigger than “Hello, World”, you need to manage builds consciously. Sometimes you want to build a binary and send it to a friend. Sometimes you want to verify that all packages build. Sometimes you want to understand why it “doesn’t build” for a teammate, but it “does build” for you. At that moment, go build stops being a spell and becomes a tool you actually control.
In Go, building is not about “compile one file”, but about “build packages and dependencies, and if one of them is main — link an executable”. And that’s very logical: in a real project, code is almost always split across folders instead of living in one huge main.go that you’re afraid to open without a hard hat.
2. Packages: how Go thinks about builds
It’s important to get used to one idea (it’s unpleasant at first, then convenient): a package in Go is a directory with .go files that declare the same package ....
In other words, Go looks at a project roughly like this:
- each folder contains one package;
- a package can import other packages;
- the build starts from the “target” you specify: the current package, a specific package, or a set of packages by pattern.
Let’s imagine a simplified tree for our training project todo (a CLI task manager). It might look like this:
todo/
go.mod
cmd/
todo/
main.go
internal/
app/
app.go
task/
task.go
And the key idea: cmd/todo is the main package (the program), while internal/app and internal/task are regular library packages (components).
To make this very concrete, here are two mini files.
cmd/todo/main.go is the program:
package main
import "fmt"
func main() {
fmt.Println("todo started") // todo started
}
internal/task/task.go is a library package (it does not run by itself):
package task
type Task struct {
Title string
Done bool
}
Building the main package will give you an executable. Building a library package will simply check that it compiles and put the result into the cache (we’ll talk about the cache below). There will be no separate “exe for the library” — and that’s normal.
3. Build targets: current package, main, and the binary
Building with no arguments and the . target
When you run go build with no arguments, you’re really saying: “build the package in the current directory”. You can say the same thing explicitly using . — it’s just a more obvious spelling.
If you’re in the cmd/todo directory, these commands are equivalent:
go build
go build .
Both mean: “build the package that lives right here”.
Now an important nuance that often surprises beginners: if the current package is not main, then after go build you may not see any new file in the folder. And that’s not “broken”, it’s “by design”: Go built the package (verified that it compiles) and used the cache.
This is convenient: you can quickly check that the internal/task package compiles without producing “junk” binaries:
cd internal/task
go build .
You won’t get a task.exe (thankfully). But if there’s an error in the code, the compiler will honestly tell you about it.
Why a binary appears only for main
Before we talk about ./..., let’s lock in the “magic marker” of a program: package main + func main().
If the package is named main but there is no main function, then the program is not considered a valid entry point. If the main function exists but the package is not main, that’s not valid either. It’s like a key and a lock: useless on their own, but together they start the application.
A mini example: “almost a program, but not”:
package main
import "fmt"
// No func main() — nothing to run.
func Start() {
fmt.Println("start") // this code will not start by itself
}
If you try to build this as a program, the compiler won’t be happy: it expects an entry point.
And here is the minimal “correct program”:
package main
import "fmt"
func main() {
fmt.Println("ok") // ok
}
The practical takeaway is very simple: if you want a binary, build the main package. Everything else is building components and verifying that they compile.
4. ./...: all packages recursively, not “one big program”
The ./... pattern is one of the most useful ones in Go tooling. It means: all packages in the current directory and in all nested directories.
So if you’re standing at the module root todo/, then:
go build ./...
means: “walk the project tree and try to build every package”.
This is often used as a quick check: “does the project compile at all?” — especially before a commit or before running tests.
And here is the most common expectation trap: ./... does not mean “build one shared binary from the whole project”. Go does not have the idea of automatically “gluing all packages into one program”. A program is a specific main package (or several main packages if you have multiple commands).
If you have a single main in cmd/todo, then go build ./... will build that too. But the result of this command is almost always not “here’s your ready release binary”, but simply the fact: “all packages compile successfully”.
To make this stick, it’s useful to compare the commands in a table:
| Command | What the target is | What you want to get | Typical result |
|---|---|---|---|
|
current package | “build what I’m standing in” | a binary (if it’s main), or just a check/cache |
|
current package (explicit) | the same, but more readable | the same |
|
all packages recursively | “make sure everything compiles” | usually no “new file”, but errors if there are problems |
|
a specific package | “build the todo command” | a todo binary in the current directory |
If we’re being short and honest: ./... is a “health check for the package tree”, not “producing a single artifact”.
5. -o: the name and location of the binary
When you build a main package, by default Go puts the binary in the current directory and names it after the last element of the package path. For example, if you run the build from the module root:
go build ./cmd/todo
then the binary will end up in the current directory (that is, in the project root) and will be called todo (or todo.exe on Windows).
This can be fine, but often you want things cleaner: for example, store binaries in ./bin/ so you don’t clutter the project root. That’s where -o comes in.
An example: “build and put it into bin”:
go build -o bin/todo ./cmd/todo
If you work on Windows, it’s usually customary to specify the extension explicitly:
go build -o bin/todo.exe ./cmd/todo
The meaning of -o is very down-to-earth: you control the output file. Not “somewhere the IDE hid it”, but directly: here’s the path, here’s the name.
Another useful scenario: you want to build a binary with a name that’s human-friendly even if the package is named differently. For example, the package ./cmd/todo might be called todo, but you want a todo-cli binary:
go build -o bin/todo-cli ./cmd/todo
And yes, you now have two sources of truth: the package name and the binary name. That’s why in real life people usually keep them close. But when you need it, -o saves you.
One more nuance (without going deep, but so it doesn’t scare you): if you try to use -o when building many packages at once (for example, ./...), Go may complain because “you can’t write many outputs into one file”. That’s logical: one file — one binary.
6. What go build does: dependencies, linking, and the build cache
It helps to understand, at least at a high level, what happens during a build. Go does it quite fast, but internally there are still stages.
Simplified, the process looks like this:
flowchart TD
A[You run go build] --> B[Go finds the target package]
B --> C[Go reads imports and builds the dependency graph]
C --> D["Compiles packages (bottom-up)"]
D --> E{Is there a main package?}
E -- no --> F[Results go to the build cache, no binary]
E -- yes --> G[Linking: build the executable]
G --> H["A binary appears (if -o is not set, in the current folder)"]
The word “linking” sounds intimidating (as if a linker is about to show up and ask whether you’re ready for adult life). In practice, it’s just the final step: “glue” the compiled pieces into one runnable file.
And here’s a very important thing that makes Go pleasant: the build cache. It explains this phenomenon:
- the first go build is a bit slower,
- the second go build is noticeably faster,
- the third one is basically “instant — I didn’t even blink”.
Why? Because if Go sees that a package and its dependencies haven’t changed, it reuses compilation results from the cache. You effectively get a “mini turbo mode” without doing anything.
This isn’t magic; it’s an engineering optimization. But like any magic, it raises questions if you don’t know it exists: “Why didn’t a file appear?” or “Why did it build so fast — maybe it didn’t do anything at all?”. It did. Just smartly.
7. A mini scenario for todo: which commands to run
So the knowledge doesn’t stay theoretical, let’s tie it to our training app todo.
Imagine three typical developer states:
The first state is “I changed main.go, I want to run the program”. The logic is simple: you need a binary, so you build the main package. If you’re in cmd/todo, then this is enough:
go build
The second state is “I changed code in internal/..., I want to make sure the whole project compiles”. Here it’s often more useful to do:
go build ./...
This gives you confidence that you didn’t break some package that isn’t involved in your local run right now, but is involved in tests or in another command.
The third state is “I want a neat binary in bin/”. Then:
go build -o bin/todo ./cmd/todo
And that’s it: the project root stays clean, artifacts are in one place, and you can either write a script or just run it by hand.
8. Common mistakes
Mistake #1: expecting go build ./... to create “one shared binary”.
This is a very natural expectation if you come from a world where you can “build the whole project”. In Go, ./... means “all packages recursively”, i.e., a compilation check of the package tree. A binary is created only when building a specific main package, not “everything at once”.
Mistake #2: confusing a package with a file and trying to think “I’ll build main.go”.
Yes, technically go build can accept a list of files, but that’s more of an emergency exit for small experiments. A normal Go project is built as packages: go build ./cmd/todo, go build ., go build ./.... Once you start thinking in terms of directories and import paths, a lot becomes simpler and more predictable.
Mistake #3: running go build in the “wrong” folder and being surprised that no binary appears.
If you accidentally end up in internal/task and run go build, there won’t be a binary — it’s a library package. In that situation, the problem isn’t the build; it’s the target: to get a binary, build a main package (for example, ./cmd/todo), or cd into its directory.
Mistake #4: using -o when you’re actually building many packages.
The -o flag is about controlling the output file. If you build a single main package — great. But if you try to combine -o and ./..., you’re effectively asking Go to “spit out many results into one file”, which doesn’t make sense. In such cases, first decide which specific main package you want to build.
Mistake #5: thinking “if it’s fast the second time, it means nothing got built”.
This is the classic human vs. cache conflict. Go really can rebuild a project in fractions of a second because it reuses cached compilation results for packages. If the code hasn’t changed, a fast build is a sign of health, not a sign the compiler is slacking off.
GO TO FULL VERSION