Taking over a Go project is easier than taking over almost anything else.
Except for the one question that ought to come first.
Go code tells you a lot about itself. The format is enforced by gofmt, the dependencies sit in one file rather than three layers of configuration, errors are return values rather than exceptions caught somewhere further up, and there is no framework doing things at runtime that are not in the code. If you can read Go, you usually find your way around an unfamiliar Go codebase within a day. In a grown PHP project the same takes a week.
What the code does not tell you: whether it runs at all, which state the binary in production was built from, and what happens when the load arrives. That is where the surprises are. This article describes what I do in the first week with someone else's Go project, in this order.
Access, ownership of the accounts and a backup that can actually be restored are the groundwork for any takeover. That part is in Your developer is gone. This one is about what is specific to Go.
A disclosure up front: not every case below comes from a takeover for a client. Two concern code I wrote myself and then did not touch for months. It is the same situation. Code nobody currently has in their head is someone else's code, even when your own name is in the Git log.
Day 1: Does it run at all?
In September I cleaned up a Go service of my own, a small job runner for personal automations. On every run it wrote metrics to a file. That file had last been written on 17 March. So the service had not run for six months, and nobody had noticed, me included.
The deploy was worse. After the repository moved, the compose file pointed at an image path that no longer existed. Four deploys in four days reported success while the service had been shut down and never came back up. The SSH step in the pipeline did not pass on the exit code of the command on the server. Green was the tool's report, not the state of the server.
Since then my first question about any unfamiliar service is not “what is the code like” but “does it run, and how can I tell”. I check three things, on the target system, not in the repository:
- The last real work. Not the last deploy, but the last log line that shows a processed request or a completed job. A health check that says “ok” only shows that the process is alive.
- The running image. Which tag, which digest, built when. The tag
latestis not an answer. - The state inside the binary. This is where Go helps more than any other language I have worked with.
go version -m ./serviceThe command reads the build information the compiler has written into every binary since Go 1.18: the Go version, the module path, every dependency with its version and, if it was built inside a Git checkout, the commit. Shortened, it looks like this:
service: go1.22.5
path example.com/billing/cmd/service
dep github.com/aws/aws-sdk-go-v2 v1.30.3
build vcs=git
build vcs.revision=4f1c2e9d0b7a…
build vcs.modified=trueThe last line is the interesting one. vcs.modified=true means the binary in production was built from a working copy with changes that were never committed. The code running there then exists in no repository. If the vcs lines are missing altogether, it was usually built in a Docker build without .git. That is not a mistake. But then the commit has to reach the image some other way, a label for instance, or nobody knows what is running.
Day 2: Does it build, and with what?
You build a Go project with one command. That is the theory. In practice the first build fails at one of three places, and none of them is in the README.
- The Go version. Since Go 1.21 the
goline ingo.modis a minimum requirement, and atoolchainline can ask for a specific version that the tool then downloads by itself. Build in a container withGOTOOLCHAIN=localand you get an error instead. I read the version in four places: ingo.mod, in the Dockerfile, in CI and in yesterday's binary. All four rarely agree. - Private modules. Module paths that point at an internal Git server need
GOPRIVATEand credentials. If a path points at a server that no longer exists, the project only builds as long as the module cache is warm on some machine. You find out when that machine is gone. - Cgo. A package with C code needs a C compiler in the build image. If
CGO_ENABLED=0is set somewhere because the binary is meant to be static and small, that package is exactly what breaks.
The documentation helps less than you would hope. In one grown repository, the instructions for coding agents named a Go version that had never run there, right next to a reference to a Git server that had long been switched off. Nobody had lied. The file had been correct once and was never checked again. So I trust go.mod and the binary, not the text beside them.
Why instructions like these are more dangerous for agents than for people is in Is your code too old for coding agents?
By the end of day two there is one command that runs every check the project has, the same locally and in CI:
go build ./... && go vet ./... && go test ./...Whatever is red is not fixed. It is written down. Fixing is week two.
Day 3: Which vulnerabilities are reachable?
A Go repository nobody touches still ages. In a template for gRPC services that sits in my GitHub account, GitHub reported a vulnerability rated critical in google.golang.org/grpc in September, and another one in golang.org/x/net. Nothing in the code had changed. The world around it had.
Dependabot reports that a vulnerable module sits in the dependency tree. That is useful, but too noisy for a takeover, because it also reports vulnerabilities in functions the code never calls. The narrower question is answered by govulncheck:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
govulncheck -mode=binary ./serviceIn the first call the tool follows the calls in the source code and reports only vulnerabilities the code actually reaches. In the second it checks the binary running in production. In a takeover that is often the more honest answer, because repository and binary do not have to be at the same state. Day 1 showed why.
After that I get an overview of how far behind the dependencies are:
go list -m -u all | grep '\['Every line with a version in square brackets has an update. In the first week I only update what govulncheck reports as reachable, one module per commit. The rest goes on a list and becomes part of the patch management that usually did not exist before.
Day 4: What happens under load?
Go code that is green in the tests can still stop in production. In unfamiliar codebases the reasons are surprisingly often the same, and you can find them before the load does.
I ran into the first one myself, in code an agent had written. A loop opened a file on every pass and only closed it at the end of the function instead of right after use. In a test with ten files nobody notices. In production the open handles pile up until the operating system refuses to hand out more and the service stops. Compiled, tests green, review unremarkable.
That is why I search an unfamiliar codebase for four specific patterns:
deferinside loops. In Go, the case above is almost always adeferthat only fires when the function returns. Whatever is opened in a loop belongs closed in the loop, most simply inside a small function of its own.- HTTP without timeouts.
http.Getandhttp.DefaultClientwait without a time limit. Anhttp.ServerwithoutReadHeaderTimeoutwaits just as patiently for slow clients. When an external service hangs, it holds on to goroutines until memory runs out. - Goroutines that never end. Every
gostatement needs a way to end, usually through acontext. The visible symptom in operation is a goroutine count that only ever rises. - Data races. There is a tool for that, and I run it once against every unfamiliar codebase.
go test -race ./...
staticcheck ./...The race detector needs cgo and makes the tests noticeably slower. It therefore belongs in a separate CI step, not in every local run. If it finds nothing during a takeover, that usually just means the tests do not touch the concurrent paths. If it finds something, it is real: it only reports races that actually happened during the run.
Day 5: What the error messages hide
On the last day I read what the service writes in operation, not what is in the code. This is the part that tells you most about the people who built it.
In a service I am currently working on, the log sporadically reported that campaigns could not be resolved. That sounded like missing data. The cause was sporadic timeouts when accessing DynamoDB and Valkey, and the message reliably pointed in the wrong direction. Search for missing campaigns and you find none.
In Go this almost always takes the same form: an error is replaced by a new message instead of being wrapped. The difference is one line, and it decides whether the log shows the cause or only its consequence.
// loses the cause
return errors.New("campaign not resolved")
// keeps it
return fmt.Errorf("resolve campaign %s: %w", id, err)So I look for errors.New and fmt.Errorf without %w on the paths that call external systems, and I match the most frequent error messages in the log against their causes. Every message that claims something other than what happened goes on the list.
If the service is part of a longer chain, the log is not enough. How a call becomes visible across service boundaries is in Introducing OpenTelemetry in PHP and Go.
What I do not do in the first week
- No new Go version. Only once the build is reproducible and the tests run. Otherwise nobody knows whether an error comes from the switch or was there before.
- No new directory layout. A project without
cmd/andinternal/is not broken. Moving things around produces diffs that make every later search through the Git log harder, and it does nothing for operation. - No framework and no ORM. Whoever introduces a web framework into someone else's Go service is not taking over. They are rebuilding, only more slowly.
- No bundled updates. A commit that bumps forty modules at once can no longer be bisected when the first error appears.
I make one exception: when secrets sit in plain text in the environment or in the repository. What that looks like done properly in Go is in Configuration and secrets for Go services.
What is on the table at the end of the week
No rebuilds. One page with four answers:
- Where the service runs, which commit it was built from, and how you can tell it is working.
- How to build it, with which Go version, and the one command that runs every check.
- Which vulnerabilities are reachable and which are already fixed.
- The three places that break first under load, each with evidence.
Add a runbook for the two most likely incidents. With that page the project has been taken over, even though not one line of code has changed. From that day the bus factor no longer hangs on the person who left.
If the code still sits with an agency, another step comes before all of this: Getting source code and access back.
Frequently asked questions
How long does a takeover take? For a single service of manageable size, about a week to reach the page above. If several services are connected through gRPC or queues, add a few days per service, and the connections between them are a topic of their own.
Does it need someone who knows Go? Not necessarily for the commands above. For judging what they find, yes: the race detector tells you there is a race, not whether it costs money. And govulncheck tells you a vulnerability is reachable, not whether it can be exploited from outside.
Can a coding agent do the onboarding? The orientation, yes, and it does it well: following call paths, describing packages, writing tests that pin down today's behaviour. Not the questions from day 1 and day 5, because their answers are not in the repository but on the server and in the log.
What I would do this week
If you have a Go service running that nobody currently has in their head, I would run two commands: go version -m against the binary running in production, and govulncheck -mode=binary against the same file. The first tells you which code is really running there. The second tells you whether it is vulnerable.
Both take a minute. If the commit matches the repository and the second command reports nothing, the takeover is a manageable project. Otherwise you now know how the week begins.
Go code tells you almost everything about itself. Except whether it runs.
If you are looking for someone who has done this week before: freelance Go developer for backends and distributed systems.

