# Go Development Ubuntu Setup Adding Go development on top of an Ubuntu machine already configured per [[General_Development_Ubuntu_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the bash setup all work identically. This guide only covers the Go-specific additions, and everything installs the same way on `amd64` and `arm64`. It's the Linux counterpart to [[Go_Development_Mac_Tahoe_Setup]]. > [!tip] AI assistance is optional > The reference to Claude Code above assumes you completed Part 7 of the General guide. If you skipped it or use a different AI tool, this guide's instructions still work — the language toolchain doesn't depend on any AI being present. > [!info] Coexistence > C, Python, Ruby, Go, and Rust can all live on the same system without interfering. Each language's toolchain installs to its own prefix: `uv` under `~/.local/share/uv/`, `rv` under `~/.cargo/bin`, Go under `/usr/local/go` (binary) and `~/go/` (module cache + user binaries), Rust under `~/.rustup/` and `~/.cargo/`, C tooling under apt. No conflicts. --- ## No version manager needed (usually) Unlike Python or Ruby, Go has a much simpler version story. The official recommendation is: 1. **One current Go install** for day-to-day work 2. **Per-version installs** via `go install golang.org/dl/goX.Y.Z@latest` when you need a specific older version for a legacy project No `pyenv`/`rbenv`/`mise` equivalent is typically necessary. Go's backward compatibility guarantees mean most projects work on the current stable. --- ## Install Go Ubuntu's apt `golang-go` package tends to lag behind the current release, so the canonical approach on Linux is the **official tarball from go.dev**, which ships separate `linux-amd64` and `linux-arm64` archives. This snippet detects your architecture and installs the latest stable to `/usr/local/go`: ```bash # Detect architecture (amd64 or arm64) and fetch the latest version string ARCH=$(dpkg --print-architecture) # "amd64" or "arm64" GO_VERSION=$(curl -fsSL https://go.dev/VERSION?m=text | head -n1) # Download and install to /usr/local/go curl -fsSL "https://go.dev/dl/${GO_VERSION}.linux-${ARCH}.tar.gz" -o /tmp/go.tar.gz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf /tmp/go.tar.gz ``` Add Go to your PATH in `~/.bashrc`: ```bash # Go toolchain + user-installed binaries export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH" ``` Reload with `source ~/.bashrc`, then verify: ```bash go version ``` > [!info] Simpler alternative: apt or snap > If you don't need the newest Go, `sudo apt install -y golang-go` is one command and works on both architectures (it just may be a release or two behind). The Snap (`sudo snap install go --classic`) tracks current Go closely. The tarball above is the recommended path because it's the official build and always current. ### Configure paths Go uses two directories by default: - **`GOPATH`** — where modules are cached and user-installed binaries live. Defaults to `~/go`. - **`GOROOT`** — where Go itself is installed (`/usr/local/go` with the tarball). Don't override it. The PATH line above already adds `$HOME/go/bin`, so `go install`-ed binaries are callable. ### Installing older Go versions (when needed) ```bash # Install the 1.21.x downloader helper go install golang.org/dl/go1.21.13@latest # Download and install that version go1.21.13 download # Use it explicitly go1.21.13 version go1.21.13 build . ``` Each version is invoked by its versioned command name; they don't interfere with your default `go`. --- ## Global Go tooling Install these once — they're used across all Go projects: ```bash # Official language server (powers editor intelligence) go install golang.org/x/tools/gopls@latest # Delve — the Go debugger go install github.com/go-delve/delve/cmd/dlv@latest # staticcheck — the de facto standard linter beyond `go vet` go install honnef.co/go/tools/cmd/staticcheck@latest # goimports — auto-manages imports go install golang.org/x/tools/cmd/goimports@latest # golangci-lint — aggregator that runs many linters at once # (official install script — architecture-aware) curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ | sh -s -- -b "$(go env GOPATH)/bin" ``` All of these land in `~/go/bin/`, which is on your PATH from the earlier step. Verify: ```bash gopls version dlv version staticcheck -version goimports -h golangci-lint --version ``` > [!info] Why the install script for golangci-lint? > On macOS the Mac guide uses `brew install golangci-lint`. On Linux, the project's official install script fetches the right prebuilt binary for your architecture — no Homebrew needed, and it stays decoupled from apt's slower release cycle. --- ## VS Code extensions for Go ```bash # The official Go extension from the Go team code --install-extension golang.go ``` That's it — one extension. After installing, open any `.go` file and VS Code will offer to install Go's supporting tools (gopls, dlv, etc.). If you already ran the `go install` commands above, it'll detect them. > [!info] Why just one extension? > Go's official extension bundles everything: gopls language server, Delve debugger, test runner, and integration with every standard Go tool. --- ## VS Code settings Append to `~/.config/Code/User/settings.json`: ```json { // ── Go ──────────────────────────────────────────────────── "[go]": { "editor.defaultFormatter": "golang.go", "editor.tabSize": 4, "editor.insertSpaces": false, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" } }, // Go tooling "go.useLanguageServer": true, "go.lintTool": "golangci-lint", "go.lintOnSave": "package", "go.formatTool": "goimports", "go.testOnSave": false, "go.coverOnSave": false, "go.toolsManagement.autoUpdate": true, "gopls": { "ui.semanticTokens": true, "ui.completion.usePlaceholders": true, "ui.diagnostic.staticcheck": true } } ``` > [!info] Tabs, not spaces, in Go > Go canonically uses tabs for indentation — it's enforced by `gofmt`. Don't fight it. --- ## Full demo: A calculator program This builds the same four-function calculator as the other guides, but Go-idiomatic. ### Create the project ```bash cd ~/projects mkdir calc-go && cd calc-go # Initialize module — the path is by convention your GitHub path, # even if you haven't created the repo yet. Replace "yourusername". go mod init github.com/yourusername/calc-go ``` This creates `go.mod`, Go's dependency manifest. ### `calc.go` — the core logic ```go // Package calc implements a four-function calculator. package calc import ( "fmt" ) // Add returns a + b. func Add(a, b float64) float64 { return a + b } // Sub returns a - b. func Sub(a, b float64) float64 { return a - b } // Mul returns a * b. func Mul(a, b float64) float64 { return a * b } // Div returns a / b, or an error if b is zero. func Div(a, b float64) (float64, error) { if b == 0 { return 0, fmt.Errorf("division by zero") } return a / b, nil } // Calculate dispatches to the appropriate operation based on op. // Supported ops: "+", "-", "*", "x", "/". func Calculate(a float64, op string, b float64) (float64, error) { switch op { case "+": return Add(a, b), nil case "-": return Sub(a, b), nil case "*", "x": return Mul(a, b), nil case "/": return Div(a, b) default: return 0, fmt.Errorf("unknown operator %q", op) } } ``` ### `cmd/calc/main.go` — the command-line entry point Create the directory first: `mkdir -p cmd/calc` ```go // Command calc is a four-function command-line calculator. package main import ( "fmt" "os" "strconv" calc "github.com/yourusername/calc-go" ) func main() { if len(os.Args) != 4 { printUsage() os.Exit(1) } a, err := strconv.ParseFloat(os.Args[1], 64) if err != nil { fmt.Fprintf(os.Stderr, "Error: invalid number %q\n", os.Args[1]) os.Exit(1) } b, err := strconv.ParseFloat(os.Args[3], 64) if err != nil { fmt.Fprintf(os.Stderr, "Error: invalid number %q\n", os.Args[3]) os.Exit(1) } result, err := calc.Calculate(a, os.Args[2], b) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Print as integer if it came out whole if result == float64(int64(result)) { fmt.Println(int64(result)) } else { fmt.Printf("%g\n", result) } } func printUsage() { prog := os.Args[0] fmt.Fprintf(os.Stderr, "Usage: %s <number> <op> <number>\n", prog) fmt.Fprintln(os.Stderr, " op: + - * /") fmt.Fprintf(os.Stderr, "Example: %s 6 + 7\n", prog) } ``` ### `calc_test.go` — tests using Go's built-in testing ```go package calc import ( "testing" ) func TestCalculate(t *testing.T) { tests := []struct { name string a float64 op string b float64 want float64 wantErr bool }{ {"add", 6, "+", 7, 13, false}, {"sub", 10, "-", 3, 7, false}, {"mul with *", 4, "*", 5, 20, false}, {"mul with x", 4, "x", 5, 20, false}, {"div", 20, "/", 4, 5, false}, {"div by zero", 5, "/", 0, 0, true}, {"unknown op", 1, "?", 2, 0, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := Calculate(tc.a, tc.op, tc.b) if (err != nil) != tc.wantErr { t.Fatalf("Calculate(%v, %q, %v) error = %v; wantErr %v", tc.a, tc.op, tc.b, err, tc.wantErr) } if !tc.wantErr && got != tc.want { t.Errorf("Calculate(%v, %q, %v) = %v; want %v", tc.a, tc.op, tc.b, got, tc.want) } }) } } ``` ### `.golangci.yml` — linter config ```yaml run: timeout: 2m linters: enable: - gofmt - goimports - govet - staticcheck - errcheck - revive - unused - ineffassign - gosimple issues: exclude-use-default: false ``` ### `.gitignore` ``` # Binaries calc calc-go /bin/ # Go test output *.out *.test coverage.html # IDE .vscode/settings.json ``` ### Build and run ```bash # Build (produces a binary named "calc" in the current directory) go build -o calc ./cmd/calc # Run ./calc 6 + 7 ./calc 10 - 3 ./calc 4 x 5 ./calc 20 / 4 # Or just run directly without producing a binary go run ./cmd/calc 6 + 7 # Install globally into ~/go/bin (so you can call `calc` from anywhere) go install ./cmd/calc calc 6 + 7 # Tests go test ./... # Verbose tests with coverage go test -v -cover ./... # Lint golangci-lint run # Format all files in place gofmt -w . # or with imports auto-managed: goimports -w . ``` ### Debug in VS Code The Go extension handles debugging automatically via Delve. Create `.vscode/launch.json`: ```json { "version": "0.2.0", "configurations": [ { "name": "Debug calc", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/calc", "args": ["6", "+", "7"] }, { "name": "Debug tests", "type": "go", "request": "launch", "mode": "test", "program": "${workspaceFolder}" } ] } ``` Set a breakpoint in `calc.go`, hit **F5**, and you'll step through with full variable inspection. Delve handles goroutines correctly, which matters for any concurrent Go code. ### Publish to GitHub ```bash git init git add . git commit -m "Initial commit: four-function calculator" gh repo create calc-go --public --source=. --remote=origin --push ``` > [!tip] Make it public for Go > Go's module system fetches dependencies directly from public git repos. If you plan to ever import this module elsewhere (`go get github.com/yourusername/calc-go`), the repo must be public. Private modules work too but need extra auth config. ### `CLAUDE.md` for this project ```markdown # Project: calc-go ## Purpose Four-function command-line calculator in Go — pedagogical example. ## Conventions - Go (current stable), standard module layout - Package structure: root package for library code, cmd/calc for CLI - Tests colocated with code (*_test.go) - Lint with golangci-lint (config in .golangci.yml) ## Commands - Build: `go build -o calc ./cmd/calc` - Run: `go run ./cmd/calc <a> <op> <b>` - Install to $GOPATH/bin: `go install ./cmd/calc` - Test: `go test -v -cover ./...` - Lint: `golangci-lint run` - Format: `gofmt -w .` or `goimports -w .` ## Style - Tab indentation (gofmt enforced) - Standard Go style — small interfaces, explicit errors as return values - Godoc comments on every exported identifier - Table-driven tests (see calc_test.go) ``` --- ## Full demo: A calculator GUI Same calculator, wrapped in a graphical interface. This uses **Fyne**, the most popular Go GUI toolkit in 2026 — cross-platform, good Linux integration, native-feeling on both architectures. ### Why Fyne? | Option | Pros | Cons | |--------|------|------| | **Fyne** | Cross-platform, good Linux integration, Material-inspired widgets, simple API | Uses CGo (needs a C compiler + GL/X11 dev headers) | | **Wails** | HTML/JS frontend, Go backend — great for web devs | Bundles a webview | | **Gio** | Immediate-mode, very fast | Steeper learning curve | | **go-app** | PWA-style, runs in browser and as desktop | Needs a server or WebAssembly | For a pedagogical calculator in Go, Fyne is the right default. ### System dependencies for Fyne Fyne uses CGo and renders through OpenGL, so it needs a C compiler and the X11/GL development headers. These are in apt for both architectures: ```bash sudo apt install -y gcc libgl1-mesa-dev xorg-dev ``` > [!info] `build-essential` already covers gcc > If you followed the General guide (or the C guide), `gcc` is already present. `xorg-dev` and `libgl1-mesa-dev` are the new pieces Fyne needs. ### Add Fyne to the project ```bash go get fyne.io/fyne/v2@latest go mod tidy ``` ### `cmd/calc-gui/main.go` — the GUI Create the directory first: `mkdir -p cmd/calc-gui` ```go // Command calc-gui is the graphical version of the four-function calculator. package main import ( "fmt" "strconv" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" calc "github.com/yourusername/calc-go" ) // CalculatorState holds the calculator's internal state. // Factored out so it can be unit-tested independently of Fyne. type CalculatorState struct { Current string Stored float64 PendingOp string HasStored bool Display string } func NewCalculatorState() *CalculatorState { return &CalculatorState{Display: "0"} } func (s *CalculatorState) HandlePress(label string) { switch { case label >= "0" && label <= "9", label == ".": s.Current += label s.Display = s.Current case label == "+" || label == "-" || label == "*" || label == "/": s.applyPending() s.PendingOp = label case label == "=": s.applyPending() s.PendingOp = "" } } func (s *CalculatorState) Clear() { s.Current = "" s.Stored = 0 s.PendingOp = "" s.HasStored = false s.Display = "0" } func (s *CalculatorState) applyPending() { if s.Current == "" { return } value, err := strconv.ParseFloat(s.Current, 64) if err != nil { s.Display = "Error" s.resetInternal() return } if !s.HasStored || s.PendingOp == "" { s.Stored = value s.HasStored = true } else { result, err := calc.Calculate(s.Stored, s.PendingOp, value) if err != nil { s.Display = "Error" s.resetInternal() return } s.Stored = result } if s.Stored == float64(int64(s.Stored)) { s.Display = strconv.FormatInt(int64(s.Stored), 10) } else { s.Display = fmt.Sprintf("%g", s.Stored) } s.Current = "" } func (s *CalculatorState) resetInternal() { s.Current = "" s.Stored = 0 s.PendingOp = "" s.HasStored = false } var buttonGrid = [][]string{ {"7", "8", "9", "/"}, {"4", "5", "6", "*"}, {"1", "2", "3", "-"}, {"0", ".", "=", "+"}, } func main() { a := app.New() w := a.NewWindow("Calculator") w.Resize(fyne.NewSize(280, 360)) state := NewCalculatorState() displayLabel := widget.NewLabel(state.Display) displayLabel.Alignment = fyne.TextAlignTrailing displayLabel.TextStyle = fyne.TextStyle{Monospace: true, Bold: true} // Build button grid var rows []fyne.CanvasObject rows = append(rows, container.NewPadded(displayLabel)) for _, row := range buttonGrid { var rowButtons []fyne.CanvasObject for _, label := range row { label := label // capture for closure btn := widget.NewButton(label, func() { state.HandlePress(label) displayLabel.SetText(state.Display) }) rowButtons = append(rowButtons, btn) } rows = append(rows, container.NewGridWithColumns(4, rowButtons...)) } clearBtn := widget.NewButton("Clear", func() { state.Clear() displayLabel.SetText(state.Display) }) rows = append(rows, clearBtn) w.SetContent(container.NewVBox(rows...)) w.ShowAndRun() } ``` ### Tests for the GUI state machine Since `CalculatorState` is pure logic (no Fyne types), it can be unit-tested without a display. Create `cmd/calc-gui/main_test.go`: ```go package main import "testing" func TestCalculatorState(t *testing.T) { tests := []struct { name string presses []string want string }{ {"initial display", []string{}, "0"}, {"single digit", []string{"5"}, "5"}, {"multi-digit", []string{"1", "2", "3"}, "123"}, {"addition", []string{"6", "+", "7", "="}, "13"}, {"subtraction", []string{"1", "0", "-", "3", "="}, "7"}, {"multiplication", []string{"4", "*", "5", "="}, "20"}, {"division", []string{"2", "0", "/", "4", "="}, "5"}, {"chained left-to-right", []string{"2", "+", "3", "*", "4", "="}, "20"}, {"division by zero", []string{"5", "/", "0", "="}, "Error"}, {"decimal", []string{"1", ".", "5", "+", "2", ".", "5", "="}, "4"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := NewCalculatorState() for _, p := range tc.presses { s.HandlePress(p) } if s.Display != tc.want { t.Errorf("after presses %v, display = %q; want %q", tc.presses, s.Display, tc.want) } }) } } func TestCalculatorClear(t *testing.T) { s := NewCalculatorState() for _, p := range []string{"5", "+", "3"} { s.HandlePress(p) } s.Clear() if s.Display != "0" || s.Current != "" || s.HasStored { t.Errorf("Clear() did not reset state: %+v", s) } } ``` ### Run everything ```bash # Run the CLI go run ./cmd/calc 6 + 7 # Run the GUI go run ./cmd/calc-gui # Run all tests (library + CLI + GUI state machine) go test ./... # Build standalone binaries go build -o calc ./cmd/calc go build -o calc-gui ./cmd/calc-gui ./calc-gui ``` > [!info] Shipping Fyne apps > For a distributable Linux package (or a `.tar.xz` with a `.desktop` file and icon), install the Fyne CLI and use `fyne package`: > ```bash > go install fyne.io/tools/cmd/fyne@latest > fyne package -os linux -icon Icon.png > ``` --- ## Starship prompt — Go auto-detection Starship's built-in `[golang]` module shows the active Go version when a `.go` file or `go.mod` is in the directory. Your prompt will show something like: ``` ~/projects/calc-go main 1.24.4 ❯ ``` --- ## Installing CLI tools from other Go projects Go's `go install` is its equivalent of `uv tool install` — it installs binaries globally (to `$GOPATH/bin`) from any Go repository: ```bash # Install hey (HTTP load testing tool) go install github.com/rakyll/hey@latest # Install air (live-reload for Go apps) go install github.com/air-verse/air@latest # Install gh-dash (fancier gh pr list) go install github.com/dlvhdr/gh-dash@latest ``` These land in `~/go/bin/` and are callable by name anywhere. --- ## direnv — auto-activate this project's environment With direnv hooked into your shell (see [[General_Development_Ubuntu_Setup]]), an `.envrc` in the project root loads when you `cd` in. Your global `$GOPATH/bin` is already on `PATH`; direnv is useful for keeping a project's `go install` output and built binaries local to the project. ```bash # from the project root cat > .envrc <<'EOF' export GOBIN="$PWD/bin" PATH_add bin EOF direnv allow ``` > [!info] You usually don't need `layout go` > direnv's built-in `layout go` rewrites `GOPATH` to a project-local cache — a pre-modules holdover you rarely want today. Setting `GOBIN` and `PATH_add bin` is enough to run `go install`-ed tools and `go build -o bin/...` output by name. --- ## Troubleshooting > [!warning] `go: command not found` after installing the tarball > Confirm `/usr/local/go/bin` is on PATH: `echo $PATH | tr ':' '\n' | grep go`. If missing, add `export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"` to `~/.bashrc` and open a fresh terminal. > [!warning] `gopls` or `dlv` not found after `go install` > Your `$GOPATH/bin` (`~/go/bin`) isn't on PATH. Same fix as above — the PATH line includes both directories. > [!warning] Fyne build fails: "gl.h: No such file" or "X11/Xlib.h not found" > Install the CGo/GL/X11 headers: `sudo apt install -y gcc libgl1-mesa-dev xorg-dev`. > [!warning] Fyne window doesn't open in a VM > Fyne needs OpenGL. Enable 3D acceleration in the hypervisor. The CLI and the state-machine tests run fine without a display. > [!warning] `go build` complains about `GOPROXY` or network issues > Go fetches modules from `proxy.golang.org` by default. On a restrictive network, set `GOPROXY=direct` temporarily. --- ## Summary — the one-shot Go addition > [!warning] This is a checklist, not a script > Copy and paste one block at a time. The `source ~/.bashrc` step and the `go install` commands depend on the PATH addition taking effect. After running these, **append the Go settings block** above to `~/.config/Code/User/settings.json`. ```bash # Go itself (official tarball, architecture-aware) ARCH=$(dpkg --print-architecture) GO_VERSION=$(curl -fsSL https://go.dev/VERSION?m=text | head -n1) curl -fsSL "https://go.dev/dl/${GO_VERSION}.linux-${ARCH}.tar.gz" -o /tmp/go.tar.gz sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf /tmp/go.tar.gz echo 'export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"' >> ~/.bashrc source ~/.bashrc # Global Go tooling go install golang.org/x/tools/gopls@latest go install github.com/go-delve/delve/cmd/dlv@latest go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/tools/cmd/goimports@latest curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ | sh -s -- -b "$(go env GOPATH)/bin" # Fyne system dependencies (for the GUI demo) sudo apt install -y gcc libgl1-mesa-dev xorg-dev # VS Code extension (just one — it bundles everything) code --install-extension golang.go # Verify go version && gopls version && dlv version && golangci-lint --version ``` About three minutes on top of the general setup. --- ## Related notes - [[General_Development_Ubuntu_Setup]] - [[Go_Development_Mac_Tahoe_Setup]] — the macOS counterpart - [[Python_Development_Ubuntu_Setup]] - [[C_Development_Ubuntu_Setup]] - [[Ruby_Development_Ubuntu_Setup]] - [[Rust_Development_Ubuntu_Setup]] - [Go Module Patterns](https://go.dev/ref/mod) - [Delve Debugging Cheat Sheet](https://github.com/go-delve/delve/tree/master/Documentation/cli)