Go as a primary language for solo practitioner tooling
At 11:40 p.m., a "tiny" background service dragged me back to the keyboard because a Python dependency had changed under it. The job was boring on paper: poll a vendor API, normalize a few fields, write one SQLite row, and expose a health check. But boring code that runs unattended has a way of becoming infrastructure, which is why I now reach for Go for solo tools that need to survive without much attention.
That first version was Python. It worked until a virtualenv drifted, a transitive package changed behavior, and my unattended tool became attended in the least charming sense. I rewrote it in Go, shipped it as one binary with a small systemd unit, and the maintenance surface dropped to a size I could keep in my head.
TL;DR
Solo practitioner tooling should optimize for boring operations, not fast first drafts. Go is a strong default for unattended solo tools because it builds a single binary, ships a capable HTTP stack in the standard library, and catches a useful class of mistakes at compile time. The tradeoff is less scripting convenience, but the payoff is fewer runtime surprises years later.
Most solo builders reach for Python or Node because the first hour is pleasant. I do it too. The trap is that many personal tools are not actually scripts. They are small services with long half-lives: cron jobs, webhooks, sync daemons, report generators, uptime checks, RSS processors, billing reconciliations, and private dashboards. They run when I am asleep, distracted, traveling, or being optimistic about future me's patience.
Before picking a language, I find it useful to classify the tool itself. A tool that runs once and exits is a script. A tool that runs on a schedule or responds to external triggers and accumulates state is a service. Services have meaningfully different operational requirements: they need structured logs, a real config path, a deployable artifact, and a restart story. The right question for a service is not "Which language gets me to the first green run fastest?" It is "Which artifact will still be legible, deployable, and debuggable when I have forgotten its clever parts?" My answer has become Go, but only in the quadrant where the tool is long-lived, unattended, stateful, and light on external library dependencies. Outside that quadrant I still use Python and sleep fine.
Why I Use Go for Solo Tools
Go is not magic. It is not the most expressive language I use. It has a habit of making small abstractions look like paperwork. But it has three traits that matter disproportionately when the operator, maintainer, incident responder, and budget approver are the same tired person.
The Artifact Is The Deployment
With Go, I can usually turn a tool into a static-ish Linux binary and copy it to a server. No node_modules, no virtualenv, no package manager ritual on the host, no delicate story about which minor version of a runtime is installed. The Go project documents this directly in its go build command reference, and that boring build path is exactly what I want for tools I will forget about until they page me.
cd ~/solo-tools/syncd
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dist/syncd ./cmd/syncd
scp dist/syncd [email protected]:/srv/syncd/syncd
ssh [email protected] 'sudo systemctl restart syncd.service'That command is not sophisticated. That is the point. A deployment process I can explain in one breath has fewer places to hide a bad assumption.
The operational difference shows up months later. If a Python service fails, I first ask which interpreter, which environment, which installed packages, and whether the server looks like my laptop. If a Go binary fails, I start with the binary, the config, the network, and the logs. That shorter diagnostic tree is not glamorous, but glamour is a poor incident response strategy.
One concrete place Go still makes me pay: SQLite. If I need CGO for mattn/go-sqlite3, cross-compilation becomes a toolchain problem. The CGO_ENABLED=0 build above sidesteps it only because I can use a pure-Go SQLite driver. When I cannot, the cross-compile story gets messy fast. That is a real cost, not a theoretical one.
The Standard Library Is Enough More Often Than People Admit
A surprising amount of solo tooling needs HTTP, JSON, time, files, TLS, logging, signals, and maybe SQLite or Postgres. Go's standard library handles most of that without sending me shopping; the net/http package is usually the center of the small services I keep around.
Here is the shape of the service I keep reusing, trimmed to the part that matters:
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"time"
)
type app struct {
client *http.Client
log *slog.Logger
}
func main() {
a := &app{
client: &http.Client{Timeout: 12 * time.Second},
log: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", a.health)
mux.HandleFunc("POST /jobs/run", a.runJob)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
a.log.Info("service starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
a.log.Error("service failed", "err", err)
os.Exit(1)
}
}
func (a *app) health(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
func (a *app) runJob(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := runSync(ctx, a.client); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusAccepted)
}No routing libraries, no third-party logging package, no HTTP framework. The standard library version is simply good enough, and "good enough" with fewer moving parts is a serious engineering property for solo maintenance.
Compile-Time Friction Is A Feature
The case for Go is not that types prevent bugs. They prevent a certain flavor of embarrassing bug: the field rename that only fails on Tuesday, the nil-ish value that wanders through a JSON transform, the missing return path after a refactor.
In a one-person codebase, review is mostly a conversation between present me and future me. The compiler is the only reviewer who reliably shows up sober.
type VendorEvent struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
type StoredEvent struct {
VendorID string
AccountID string
SeenAt time.Time
}
func normalize(e VendorEvent, now time.Time) (StoredEvent, error) {
if e.ID == "" {
return StoredEvent{}, fmt.Errorf("missing vendor id")
}
if e.AccountID == "" {
return StoredEvent{}, fmt.Errorf("missing account id")
}
return StoredEvent{
VendorID: e.ID,
AccountID: e.AccountID,
SeenAt: now.UTC(),
}, nil
}That is not a grand type system. It is a fence. A small, plain fence. It keeps routine mistakes from becoming production archaeology.
The Shape I Use Now
I think about these tools in three layers: command, service, and storage. The important move is keeping the core work separate from how it is triggered. A sync job should not care whether it was started by cron, a webhook, or me poking it with curl after reading a suspicious log line. That same separation shows up in Building Press, Part 2: The data model is the spine, where the structure matters because the project has to survive more than one clever afternoon.
The cmd/ package wires flags, environment variables, logging, and transport. The internal package does the work. This is the smallest structure that has paid for itself repeatedly.
syncd/
cmd/syncd/main.go
internal/sync/run.go
internal/sync/vendor.go
internal/store/sqlite.go
migrations/001_events.sqlThe database schema is plain because the workload is plain:
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
vendor_id TEXT NOT NULL UNIQUE,
account_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_seen_at_idx
ON events (seen_at);And the service manager config is dull enough to survive contact with a Sunday afternoon:
[Unit]
Description=Sync daemon
After=network-online.target
Wants=network-online.target
[Service]
User=syncd
Group=syncd
WorkingDirectory=/srv/syncd
EnvironmentFile=/etc/syncd.env
ExecStart=/srv/syncd/syncd
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetOne thing worth watching: Restart=on-failure with a short RestartSec can paper over a bad job that crashes in a loop. I have had that hide a malformed config for longer than I'd like to admit. Structured JSON logs help here; a quick journalctl -u syncd.service -n 80 --no-pager makes restart loops obvious if you are looking for them.
The health check is equally unromantic:
curl -fsS http://127.0.0.1:8080/healthz
journalctl -u syncd.service -n 80 --no-pagerA good solo tool should be easy to interrogate when you are annoyed. Especially then.
What I Rejected
I did not stop using Python or Node. I stopped defaulting to them for tools that need to run unattended.
| Option | Why It Was Tempting | Why I Rejected It Here |
|---|---|---|
| Python script | Fast iteration, great libraries | Runtime and dependency drift became part of operations |
| Node service | Convenient JSON and HTTP ecosystem | Install size and package churn were disproportionate for the job |
| Shell pipeline | Excellent for one-off glue | Error handling and retries got unreadable quickly |
| Container only | Reproducible host story | Added registry, image, and runtime concerns for a tiny binary |
The container point is worth being precise about. Containers are useful when I need system dependencies, identical runtime images, or orchestration. For a single binary on one small host, a container can be a second deployment system wrapped around a first one. Sometimes that is worth it. Often it is just more furniture in a small room.
Deep-dive: The boring release script
This is the kind of release wrapper I will tolerate. It builds, copies, restarts, and verifies. It does not invent a platform.
#!/usr/bin/env bash
set -euo pipefail
host="[email protected]"
service="syncd.service"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o dist/syncd ./cmd/syncd
ssh "$host" 'sudo install -d -o syncd -g syncd /srv/syncd'
scp dist/syncd "$host:/tmp/syncd.new"
ssh "$host" 'sudo install -o syncd -g syncd -m 0755 /tmp/syncd.new /srv/syncd/syncd'
ssh "$host" "sudo systemctl restart $service"
ssh "$host" "systemctl is-active --quiet $service"
ssh "$host" 'curl -fsS http://127.0.0.1:8080/healthz >/dev/null'The Cost Go Still Carries
Go makes the long tail cheaper, but the first draft is often less fluid. JSON transformations take more code than they do in Python. Small CLIs can feel ceremony-heavy until the structure settles. If the tool is truly disposable, or if it leans on a mature Python library that would be expensive to replace, I still use Python and sleep fine.
There is also a taste cost. Go encourages explicit handling of errors, configuration, and data shape. That can feel like the language is making you fill out forms before it lets you solve the problem. For throwaway work, that irritation is real. For unattended work, the forms become receipts.
The compiler is the only reviewer who reliably shows up sober.
Here is the decision I actually run: one-off task with no retry or state requirement, script it in Python or shell. Recurring job expected to run for a year or more, Go becomes the strong default. Add HTTP and a process manager, and Go is where I start unless a library dependency argues otherwise. That third case is real: if a vendor's Python SDK is the path of least resistance and there is no Go equivalent worth the port, Python wins. The same bias toward boring operations is why I keep coming back to small, visible systems like the one in One Binary, One Box. That is the real argument for Go for solo tools: less romance at the beginning, fewer surprises at 11:40 p.m.
FAQ
Why use Go for solo practitioner tooling?
Use Go when the tool needs to run unattended for months or years. A single binary, standard library HTTP, and compile-time checks reduce the operational drift that hurts small long-lived services.
Is Go better than Python for small automation?
For one-off automation, Python is often faster. For recurring jobs and small services, Go usually has a lower maintenance burden because deployment has fewer runtime dependencies.
Where should I draw the line between a script and a service?
If the tool has retries, persistent state, health checks, webhooks, or scheduled unattended execution, treat it like a service. That usually means structured logs, a real config path, and a deployable artifact.
Do I need Docker for Go solo tools?
Not necessarily. A Go binary plus systemd is often enough for one host, though containers still make sense when you need system packages, orchestration, or uniform images across several environments.
What is the biggest downside of Go for glue work?
The first version takes more ceremony than Python or shell. The trade is that Go moves some pain from runtime operations into compile-time and build-time checks, which is usually where I would rather pay it.
