1. Why imports are part of compilation
Imports in Go are not a decorative header on top of the code, but a real part of the contract with the compiler. Go is deliberately designed so that you cannot leave a file in a “half-working” state: if an import is extra — the build fails; if you forgot an import — the build fails; if the imports don’t match what is actually used — the build fails. But afterwards everything is predictable and without surprises.
In Go, an import is a declaration: “in this file, I will use names from these packages.” For example:
package main
import "fmt"
func main() {
fmt.Println("hello") // hello
}
Now let’s imagine two classic real-life situations.
First: you wrote strings.TrimSpace, but forgot to import strings. The code looks “almost done”, but the compiler honestly says: “I’m not a telepath.”
package main
import "fmt"
func main() {
s := " hi "
s = strings.TrimSpace(s) // strings is not imported → compile error
fmt.Println(s)
}
Second: you imported a package “for later”, but you aren’t using it yet. Go doesn’t respect that, because “for later” is a bug’s favorite excuse.
package main
import (
"fmt"
"strings" // imported, but not used → compile error
)
func main() {
fmt.Println("ok") // ok
}
Yes, it’s sometimes annoying. But it also makes Go code cleaner: you either use a dependency, or it isn’t there. No “we’ll figure out later why it doesn’t build.”
2. What goimports is and why it’s “gofmt + imports”
After gofmt usually comes the next everyday level of pain: you write code, your IDE highlights “missing import”, you add it manually, then you refactor, the import becomes unused, and again manually… a couple of hours later you realize that in programming you wanted to build applications, not curate an import collection.
goimports is a utility that does two things in one pass:
- formats the file the same way as gofmt (i.e., brings the code to a standard form);
- and at the same time automatically fixes the import block: adds missing imports, removes unused ones, sorts and groups them.
It’s very important to define the boundaries: goimports does not fix program logic, does not rewrite algorithms, and does not “get smart instead of you.” It simply guarantees that the file is neat and that imports match the real code.
For reference, here’s a mini table:
| Tool | What it does | What it doesn’t do |
|---|---|---|
|
Canonically formats Go code | Doesn’t touch imports (except formatting the block), doesn’t add packages |
|
Does everything gofmt does, plus synchronizes imports with actual usage | Doesn’t fix cyclic imports or package architecture |
| IDE “Optimize Imports” | Usually uses goimports-like logic under the hood | May behave differently across IDEs/settings |
The combination “gofmt as a standard” fits well with the idea that the Go ecosystem loves tools that simplify a developer’s life (and reduce “manual magic”).
3. How goimports knows which imports are needed
It’s important to understand the internal logic of goimports so you don’t expect miracles from it and don’t panic when it “doesn’t do what you wanted.” The utility analyzes source code and looks for name usage that comes from other packages: fmt.Println, strings.TrimSpace, strconv.Atoi, and so on. If a name is used — the import is needed; if it isn’t used — the import is extra.
For example, if you write this:
package main
import "fmt"
func main() {
fmt.Println("ok") // ok
}
then everything is obvious: fmt is used.
But if you write this:
package main
import "fmt"
func main() {
println("ok") // ok
}
then fmt is not used — goimports will remove it.
At the same time, Go has ways to import a package “for side effects” or “for non-standard access” (_-import, .-import, aliases). For this lecture, it’s enough to remember the key point: goimports can handle these constructs, but it can’t read your mind. If you wrote _ "some/package" — it will keep it, because it’s an explicit signal: “the import is needed even if names aren’t used.”
The most common case where goimports is powerless is architectural: when you have cyclic imports or you split responsibilities between packages incorrectly. That’s no longer “import hygiene”; it’s code design.
4. Practice: mini CLI “Tasker”
To make goimports feel not like “yet another command” but like a real helper, let’s continue our learning project — a small CLI “Tasker” that stores tasks in memory and can add and show them. Nothing fancy: what matters more is seeing how imports live across multiple files and how easy it is to break them with a small refactor.
We’ll make two packages: main (entry point) and task (logic). This naturally creates a situation where there’s more than one import, and manual fiddling starts to get annoying.
Starting with the task package: forgot the strings import
The first “problem feeling” usually appears like this: you added a useful line, and suddenly the program stopped building. At that moment, a beginner developer often thinks: “I broke Go.” In reality, you simply forgot a dependency, and that’s normal: a human isn’t required to remember all imports in their head (there are already enough memes living there).
Let’s create the file task/task.go:
package task
type Task struct {
Title string
Done bool
}
Now let’s add a function that normalizes input — for example, trims spaces at the edges. And here we use strings.TrimSpace.
package task
func New(title string) Task {
title = strings.TrimSpace(title) // strings is not imported yet → error
return Task{Title: title}
}
If you try to build the project now, the compiler will say that strings is undefined. And that’s exactly the moment where goimports is useful as a “cleaner after a party”: it will see strings.TrimSpace and add the import automatically.
After applying goimports, the file will look like this:
package task
import "strings"
func New(title string) Task {
title = strings.TrimSpace(title)
return Task{Title: title}
}
Notice: you added one line of logic, and the tool finished the surrounding infrastructure. And that is exactly why goimports exists.
goimports removes unused things: removed debug — removed the import
Now another classic: you were debugging, printing something via fmt.Println, then you removed the debug printing, but the fmt import stayed. In Go, that’s not “just ugly”; it’s a compile-time error, meaning the project won’t build.
Let’s imagine that temporarily we had this:
package task
import (
"fmt"
"strings"
)
func New(title string) Task {
title = strings.TrimSpace(title)
fmt.Println("creating task:", title) // debug
return Task{Title: title}
}
Then you removed the debug:
package task
import (
"fmt" // now unused
"strings"
)
func New(title string) Task {
title = strings.TrimSpace(title)
return Task{Title: title}
}
Without goimports, you’ll get an error like “imported and not used.” With goimports, it turns back into a working file:
package task
import "strings"
func New(title string) Task {
title = strings.TrimSpace(title)
return Task{Title: title}
}
An important psychological observation: Go kind of “forces you to be tidy.” goimports makes that tidiness cheap and almost automatic.
Import grouping: standard library and your packages
As a project grows, imports start to look like a small shopping list. And if you don’t agree on an order, every developer will sort “however they like,” and git diff turns into a 300-page novel about rearranging lines.
goimports follows a common convention: first the standard library, then external dependencies, then your local packages (if any). Typically groups are separated by a blank line.
Look at an example from real Go code with multiple standard packages — fmt and net/http. This is exactly the style that goimports tries to maintain automatically.
In our main.go we might end up with an import block like this:
package main
import (
"bufio"
"fmt"
"os"
"strings"
"tasker/task"
)
You can see two groups here: the standard library and our internal package tasker/task. Even if you write imports “mixed together” by hand, goimports will usually bring the block to a stable form.
When an alias is needed: two packages with the same name
In real life, you sometimes run into a situation where you import two packages that share the same package name (package name), and then you can’t refer to them the same way.
To avoid diving into complex topics, here’s an “in a vacuum” example, purely as an idea. Suppose you need two different rand packages:
package main
import (
crand "crypto/rand"
"math/rand"
)
func main() {
_ = rand.Int() // rand from math/rand
_ = crand.Reader
}
You write the crand alias by hand (that’s a developer decision), and goimports will neatly maintain the import block and formatting. In other words, goimports helps with hygiene, but choosing aliases is your responsibility and part of readability.
5. Running and integrating goimports
Running it: via the IDE and via the command line
In learning projects, goimports is most often “hidden” inside the IDE: you clicked “Optimize Imports” or simply saved the file — and imports were cleaned up. That’s convenient, but there’s a risk of not understanding what’s going on at all. And understanding matters: when the tool “breaks” on someone else’s machine or in CI, you need to be able to explain to yourself what it does.
If we talk about running it as a ritual, goimports usually has two modes.
The first is “overwrite file(s)” (the most common): you want the tool to actually apply changes.
The second is “check and show where there are differences”: useful when you want to see what exactly would be changed.
Approximate command variants (the idea, not a memorization exam on flags):
goimports -w main.go
goimports -w .
goimports -l .
The meaning is usually:
| Option | What you get |
|---|---|
|
actually rewrites files “in place” |
|
prints a list of files that need fixing |
If in your dev environment goimports is integrated into formatting “on save”, you may not think about flags at all. But it’s important to know that behind the scenes it’s exactly “formatting + import synchronization.”
Making goimports part of your workflow
The most common beginner mistake is to treat tools as “something you run at the end, when everything is already done.” The problem is that at the end you’re tired, you want to go home, and then the tool suddenly changes a bunch of files, and you get nervous: “It broke everything!” When in fact it just removed the trash that accumulated all day.
It’s much calmer to think like this: goimports is part of the “write → fix → run” cycle.
You can imagine this cycle as a simple diagram:
flowchart TD
A[Write/edit code] --> B[goimports]
B --> C[Build/run]
C --> D[If something is wrong — go back to the code]
D --> A
And this fits perfectly into the overall Go ecosystem philosophy: tools should simplify life and make the process predictable, not turn development into shamanism.
The practical effect is very down-to-earth: when you run goimports regularly, you almost never see errors like “imported and not used,” because they don’t have time to accumulate. And you get less “noise” in the diff, because imports are always in one style.
6. Typical mistakes when working with goimports
With goimports you usually won’t have “catastrophes,” but you will get small annoying surprises, especially at the beginning. That’s normal: the tool is strict, and we are human, and sometimes we want the computer to understand hints like “well, I’ll use this later.” Below are the most common rakes beginners step on, and why it happens.
Error #1: expecting goimports to “fix the code”, not only imports and formatting.
Sometimes it feels like: “if it’s smart and parses code, let it fix the compile error too.” But goimports’s responsibility is to bring the file to the standard form and align the import block with what is actually used. If you have wrong types, logic, conditions — that’s not its job.
Error #2: keeping “temporary imports” and being surprised the project doesn’t build.
After a couple of experiments, you want to keep "strings" or "strconv" “just in case.” In Go, that’s not how it’s done: an unused import breaks the build. goimports helps enforce discipline: either we use it, or we remove it. If you truly want to keep an import for side effects — that’s a separate explicit construct, not “it’s just sitting there.”
Error #3: confusing “import path” and “package name” and blaming goimports.
Sometimes a beginner expects that you import “what the package is called in code,” while in the import you write “where the package lives in the project/module.” In practice you import the path, and in code you use the package name. An example with common imports ("net/http", "fmt") shows that path and name often match, but it’s not guaranteed.
Error #4: trying to “align imports nicely with spaces”, and then being surprised everything got rewritten.
goimports (like gofmt) doesn’t respect manual “drawing with spaces.” It normalizes to the standard. It’s not because it’s mean; it’s because a single standard reduces noise in change history. If you think your variant is “prettier” — congratulations, you’ve just become an adult programmer and developed an opinion about formatting. Now you can exhale and let the tool do its job.
Error #5: running goimports “when you feel like it,” instead of as a habit.
If you run the tool once a week, it will accumulate many changes, and you’ll perceive it as “it redid half the project.” If you run it constantly (or configured the IDE “on save”), the changes are tiny and not stressful at all. That’s the point: automation should be cheap and regular; otherwise it turns into a separate stressful stage.
GO TO FULL VERSION