# 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)