# Go Development Mac Tahoe Setup
Adding Go development on top of an Apple Silicon Mac already configured per [[General_Development_Mac_Tahoe_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the zsh setup all work identically. This guide only covers the Go-specific additions.
> [!tip] AI assistance is optional
> The reference to Claude Code above assumes you completed Part 7 of the General guide. If you skipped it (which is fine — your dev environment works without any AI) or use a different AI tool (Copilot, Cursor, Continue, etc.), 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 `~/.rv/`, Go under `~/go/` (module cache) and `/opt/homebrew/bin/go` (binary), Rust under `~/.rustup/` and `~/.cargo/`, C/CMake under Homebrew. 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** via Homebrew 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 without fuss, and when they don't, the official per-version installer handles it.
If you really want a version manager (e.g., for CI reproducibility across many Go versions), `mise` and `asdf` both support Go. But for 95% of workflows, Homebrew's `go` is sufficient.
---
## Install Go
```bash
brew install go
```
This installs the latest stable Go, native arm64 on Apple Silicon. Verify:
```bash
go version
```
### 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. Set automatically by Homebrew, don't override.
The one thing you should set: add `$GOPATH/bin` to your `PATH` so `go install`-ed binaries are callable. Add to `~/.zshrc`:
```bash
# Go — enable user-installed binaries
export PATH="$HOME/go/bin:$PATH"
```
Reload with `source ~/.zshrc`.
### Installing older Go versions (when needed)
If a legacy project requires, say, Go 1.21:
```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
# golangci-lint — aggregator that runs many linters at once
brew install golangci-lint
# goimports — auto-manages imports, replaces `gofmt` for many workflows
go install golang.org/x/tools/cmd/goimports@latest
```
All of these land in `~/go/bin/`, which is on your PATH from the earlier step. Verify:
```bash
gopls version
dlv version
staticcheck -version
golangci-lint --version
goimports -h
```
---
## VS Code extensions for Go
```bash
# The official Go extension from the Go team (formerly from Microsoft)
code --install-extension golang.go
```
That's it — one extension. After installing, open any `.go` file and VS Code will prompt 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?
> Unlike the C ecosystem (multiple extensions for formatting, linting, debugging, and build tools), Go's official extension bundles everything: gopls language server, Delve debugger, test runner, and integration with every standard Go tool. It's a model of cohesive tooling.
---
## VS Code settings
Append to `~/Library/Application Support/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 C and Ruby versions, 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 (fine for development)
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 is important for any concurrent Go code.
### Publish to GitHub
Same workflow as any project (see [[General_Development_Mac_Tahoe_Setup#Publishing a repo 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 1.23+, 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 — pure Go, no CGo system-library dependency issues, native-looking on macOS.
### Why Fyne?
| Option | Pros | Cons |
|--------|------|------|
| **Fyne** | Pure Go, cross-platform, good macOS integration, Material-inspired widgets, simple API | Custom widget rendering (not native controls) |
| **Wails** | HTML/JS frontend, Go backend — great for web devs | Bundles a Chromium-derived webview |
| **Gio** | Immediate-mode, very fast, used in production by ElementsProject | Steeper learning curve |
| **go-app** | PWA-style, runs in browser and as desktop | Needs a running server or WebAssembly |
For a pedagogical calculator in Go, Fyne is the right default. Install with `go get`, build with `go build`, ship a single static binary.
### Add Fyne to the project
```bash
go get fyne.io/fyne/v2@latest
go mod tidy
```
This pulls Fyne into `go.mod` along with its dependencies.
### `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 macOS `.app` bundle with an icon, install the Fyne CLI tool and use `fyne package`:
> ```bash
> go install fyne.io/tools/cmd/fyne@latest
> fyne package -os darwin -icon Icon.png
> ```
> This produces a proper `.app` bundle that can be code-signed and notarized for distribution.
---
## 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. No config needed, but to customize append to `~/.config/starship.toml`:
```toml
[golang]
format = "[$symbol($version )]($style)"
symbol = " "
style = "bold cyan"
```
Your prompt will now show something like:
```
~/projects/calc-go main 1.23.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.
---
## Troubleshooting
> [!warning] `gopls` or `dlv` command not found after `go install`
> Your `$GOPATH/bin` isn't on PATH. Verify with `echo $PATH | grep go/bin`. If missing, add `export PATH="$HOME/go/bin:$PATH"` to `~/.zshrc` and open a fresh terminal.
> [!warning] VS Code prompts to install tools every time
> The extension auto-installs tools the first time. If it keeps prompting, run `Go: Install/Update Tools` from the command palette and pick all of them. Ensures consistent versions across tools.
> [!warning] `go build` complains about `GOPROXY` or network issues
> Go fetches modules from `proxy.golang.org` by default. If you're on a restrictive network, set `GOPROXY=direct` temporarily, or configure a local proxy like Athens for offline work.
> [!warning] "cannot find package" for a module you just created
> Go modules must have a canonical import path. If you ran `go mod init mycalc`, but your `import` uses `github.com/you/mycalc`, it won't resolve. Either match them, or use a relative path for the CLI's import of the library package.
> [!warning] `gofmt -l` or `goimports -l` shows files that need formatting in CI
> Always commit `gofmt`-clean code. Run `gofmt -w .` before committing, or configure a pre-commit hook.
---
## Summary — the one-shot Go addition
> [!warning] This is a checklist, not a script
> The block below is **not a shell script you can save and run.** It's a sequence of commands to **copy and paste into your terminal one block at a time**, in order. The `source ~/.zshrc` step and the `go install` commands that follow depend on the previous PATH addition having taken effect. After running these, you also need to **append the recommended Go settings block** from earlier in this guide to `~/Library/Application Support/Code/User/settings.json` — without that, gopls and the linter won't be properly configured.
```bash
# Go itself
brew install go golangci-lint
echo 'export PATH="$HOME/go/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
# 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
# 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_Mac_Tahoe_Setup]]
- [[Python_Development_Mac_Tahoe_Setup]]
- [[C_Development_Mac_Tahoe_Setup]]
- [[Ruby_Development_Mac_Tahoe_Setup]]
- [[Rust_Development_Mac_Tahoe_Setup]]
- [Go Module Patterns](https://go.dev/ref/mod)
- [Delve Debugging Cheat Sheet](https://github.com/go-delve/delve/tree/master/Documentation/cli)