The shell script habit
You are used to writing a few lines of bash to rename files, check a server, or parse a log. You save it as fix.sh, run chmod +x, and execute it. Go does not work that way. There is no interpreter waiting to read your file line by line. Instead, you write a program, compile it to a standalone binary, and run that binary. The tradeoff is upfront compilation time for instant, native execution and cross-platform portability.
Why Go compiles instead of interpreting
Shell scripts and Python rely on an interpreter that translates your code into machine instructions at runtime. Go uses a compiler that translates your entire program into machine code ahead of time. The resulting file contains everything it needs to run on that operating system. No runtime dependencies, no missing package managers, no version mismatches. You hand the binary to a coworker on a different machine, and it just works.
The compilation step catches type mismatches, undefined variables, and unused imports before the program ever starts. This shifts errors from runtime surprises to immediate feedback during development. You pay a few seconds for go build, but you get deterministic execution and zero startup overhead when the binary runs.
Go also handles platform differences automatically. The compiler targets your current OS and architecture. Cross-compilation is a single environment variable change away. You can build a Linux binary on macOS without installing a Linux VM. The binary is a self-contained executable that the operating system loader understands natively.
Your first runnable script
Every Go program starts with a single entry point. Here is the bare minimum to accept a command-line argument and print it back.
package main
import (
"fmt"
"os"
)
// main is the entry point for every standalone Go program.
func main() {
// os.Args holds the command-line arguments passed to the binary.
// The first element is always the program name itself.
if len(os.Args) < 2 {
fmt.Println("Usage: script <target>")
// os.Exit stops the program immediately with a non-zero status.
os.Exit(1)
}
fmt.Println("Processing:", os.Args[1])
}
Save this as script.go. Run it with go run script.go hello. The go run command compiles the file to a temporary location, executes it, and deletes the temporary binary when it finishes. It is perfect for quick testing. When you are ready to distribute the script, run go build -o script script.go. That produces a persistent binary named script that you can place in your PATH and run like any other command-line tool.
What happens under the hood
When the operating system launches your binary, the runtime initializes before your main function runs. It sets up the goroutine scheduler, allocates the heap, and populates os.Args from the process arguments. Your code then executes sequentially from top to bottom. When main returns, the runtime cleans up open file descriptors, flushes buffers, and exits with status code zero.
Exit codes matter for shell integration. Zero means success. Any non-zero value signals failure. The shell stores the last exit code in $?, which allows other scripts to react to your program's outcome. Using os.Exit(1) bypasses deferred functions, so reserve it for top-level fatal errors. If you need cleanup logic to run, return an error from a helper function and call os.Exit only after the deferred handlers finish.
The community accepts verbose error checking because it makes the unhappy path visible. You will see if err != nil { return err } repeatedly. It looks repetitive, but it forces you to decide what happens when things go wrong. Silent failures are worse than explicit boilerplate.
A realistic automation script
Real scripts rarely just print arguments. They interact with the filesystem, validate input, and report status. Here is a script that checks whether a path exists and identifies its type.
package main
import (
"fmt"
"os"
)
// main validates arguments and checks file existence.
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: check <path>")
os.Exit(1)
}
// Stat returns file metadata without reading the contents.
info, err := os.Stat(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "path not found: %v\n", err)
os.Exit(1)
}
// IsDir checks the file mode bits set by the OS.
if info.IsDir() {
fmt.Println("target is a directory")
} else {
fmt.Println("target is a file")
}
}
Notice the use of os.Stderr for error messages. Standard error is a separate stream from standard output. This keeps normal program output clean for piping into other tools, while errors still appear on the terminal. The fmt.Fprintf call formats the error value using the %v verb, which calls the error's Error() method automatically.
Run go build -o check check.go to produce the binary. Execute ./check /tmp to see the directory detection. Execute ./check nonexistent to see the error path. The binary runs instantly because the OS loads compiled machine code directly into memory. There is no startup penalty for loading an interpreter or parsing source files.
Common traps and compiler feedback
Go's compiler is strict by design. It catches mistakes that interpreted languages often hide until runtime. If you forget to declare package main, the compiler rejects the file with expected 'package', found 'func'. The main package declaration tells the compiler to build an executable instead of a library.
Unused imports trigger an immediate failure. The compiler complains with imported and not used if you include a package but never reference it. This rule keeps dependencies explicit and prevents accidental bloat. If you need a package only for its side effects, use the blank identifier: import _ "package/name". The underscore tells the compiler you intentionally ignored the package's exported symbols.
Variable shadowing is another common pitfall. If you declare a new variable inside a nested scope with the same name as an outer variable, the inner one hides the outer one. The compiler does not warn about this, but gofmt and linters will flag it. Run gofmt on every file before committing. The tool enforces a single, consistent formatting style across the entire ecosystem. Do not argue about indentation or brace placement. Let the tool decide.
Runtime panics happen when you access an out-of-bounds slice index or dereference a nil pointer. The program crashes with a stack trace. You can recover from panics using defer and recover, but idiomatic Go prefers returning errors instead. Reserve panics for truly unrecoverable conditions like invalid program invariants.
When to use Go versus bash or Python
Use Go when you need a single portable binary that runs anywhere without dependencies. Use bash when you are chaining existing command-line tools and need quick text manipulation. Use Python when you need rapid prototyping with a massive ecosystem of third-party libraries. Use Go when your script will eventually grow into a long-running service or CLI tool. Use bash when execution speed is irrelevant and readability for system administrators matters most. Use Python when you are working with data science packages or web frameworks that already exist.
Go shines in automation that requires reliability, cross-platform distribution, and gradual scaling. The compilation step removes environment drift. The standard library provides robust file I/O, HTTP clients, and JSON parsing without external packages. The type system catches structural mistakes before deployment.
Where to go next
- How to Use go list to Inspect Packages and Modules
- What Is GOROOT and When Should You Change It
- How to Generate Documentation for Your Go Package
Trust the compiler. Write small binaries. Keep dependencies out of your automation.