5.5 KiB
One-Liners and Disposable Scripts
Production hygiene with throwaway ergonomics. Go scripts get the same strict lints, the same type discipline, the same 250 LOC ceiling. The difference: they live as single .go files invoked via go run, not as full modules.
Python has PEP 723 + uv run. Rust has rust-script. Go has go run directly — no extra tooling needed.
Pattern 1: Single-file go run
A .go file with a main package, run directly:
//go:build ignore
// fetch.go — fetch a URL and print body length.
//
// Usage:
// go run fetch.go <url>
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: go run fetch.go <url>")
}
resp, err := http.Get(os.Args[1])
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { log.Fatal(err) }
fmt.Printf("%d bytes\n", len(body))
}
Run: go run fetch.go https://example.com.
The //go:build ignore directive keeps this file out of go build ./... — it is a script, not part of the module. Without that line, every .go file in the package gets compiled into your binary.
Pattern 2: Throwaway directory under scripts/
myproject/
├── go.mod
├── internal/...
└── scripts/
├── seed/
│ └── main.go # `go run ./scripts/seed`
├── migrate/
│ └── main.go
└── one-time-fix/
└── main.go
Each scripts/<name>/main.go is its own main package. Invoke as go run ./scripts/seed/. Dependencies are shared with the parent module — no separate go.mod.
This is the right pattern when:
- You need module deps (sqlc, pgx, your own internal packages).
- You want IDE support, type-checking, test coverage.
- The script lives alongside the project, runs in CI.
Pattern 3: Inline go run from shell
go run -mod=mod <(cat <<'EOF'
package main
import "fmt"
func main() { fmt.Println("hello") }
EOF
)
Rare, but useful for one-shot terminal experiments. The <(...) is process substitution; go run -mod=mod reads from stdin.
Hard rules for scripts
Even a 30-line script follows the philosophy:
-
Typed flags via
flagorpflag, notos.Argsstring parsing past 2 args.var ( url = flag.String("url", "", "URL to fetch") limit = flag.Int("limit", 100, "max bytes") ) flag.Parse() if *url == "" { log.Fatal("--url required") } -
context.Contextpropagation wherever I/O happens.ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() req, _ := http.NewRequestWithContext(ctx, "GET", *url, nil) -
log.Fatalis fine inmain()of a script (programmer error / fatal path), but never inside any function the script imports. Library code returns errors. -
Errors get wrapped. Same rule as production code:
if err != nil { return fmt.Errorf("fetch %s: %w", *url, err) } -
Resources released via
defer. No "I'll fix it later". -
slog for output if it must be parseable.
fmt.Printlnfor one-shot terminal output is fine. -
No more than 250 pure LOC. If it grows, it stops being a script and becomes a subcommand of your CLI tool.
Pattern 4: Standalone tool with deps — temporary module
Some scripts need deps the parent module does not have. Two options:
Option A — script in its own tiny module
mkdir /tmp/migrate-tool && cd $_
go mod init scratch.local/migrate-tool
go get github.com/pressly/goose/v3
cat > main.go <<'EOF'
package main
import ... // use goose
func main() { ... }
EOF
go run .
Run, then delete /tmp/migrate-tool. Throwaway.
Option B — gorun (community tool)
go install github.com/erning/gorun@latest
cat > script.go <<'EOF'
//usr/bin/env gorun "$0" "$@"; exit
// /// go.mod
// module scratch
// go 1.23
// require github.com/spf13/cobra v1.8.0
// ///
package main
...
EOF
chmod +x script.go
./script.go
gorun parses the inline go.mod block, materializes a temp module, runs the script. Niche tool — only if you want the executable-script experience.
When a script becomes a CLI
If your script needs:
- More than one subcommand
- Long-term storage of state
- Help text more than a paragraph
- Repeated invocations from CI
... promote it to a real CLI tool via cobra — see cobra-stack.md. The boundary is fuzzy; trust your judgment, but a 500-line "script" is not a script.
Antipatterns
| Bad | Why | Good |
|---|---|---|
os.Args[1] indexing without length check |
Panics on missing arg | flag.Parse() with explicit checks |
log.Fatal inside a function the script imports |
Crashes caller's process | Return error |
panic(err) for expected failures |
Same as above | log.Fatal in main, error return elsewhere |
Skipping defer resp.Body.Close() because "it's a script" |
Leaks fd | Always close |
One 800-LOC main.go "to keep it simple" |
Now harder to read than a real CLI | Promote to cmd/<name>/ with subcommands |
// TODO: handle error |
Production-grade hygiene means production-grade hygiene | Handle now or document why ignored |
Sources
go rundocs: https://pkg.go.dev/cmd/go#hdr-Compile_and_run_Go_program//go:buildconstraints: https://pkg.go.dev/cmd/go#hdr-Build_constraintssignal.NotifyContext: https://pkg.go.dev/os/signal#NotifyContext- gorun: https://github.com/erning/gorun