---
title: Manually install and auto-switch Golang versions
date: 2025-02-02
description: A custom shell-based solution for automatically installing and switching Go versions when changing directories.
tags: [go, shell]
---

If you work on muliple [Go][go-website] projects, you'll often find they require
different Go versions. So how do you handle switching between Go versions?

You can't rely on your package manager as it might only provide the latest
version of Go (such as is the case when using [Homebrew] on macOS) or it might
not provide all prior Go versions (maybe only a subset). Then you have to decide
whether the _switch_ is something you do manually or automatically when you `cd`
into a Go project directory (but how do you determine and implement that?).

Typically you'll use a third-party tool that does this for you. I've
historically used a lot of tools. The last tool I used was
[stefanmaric/g][github-g] and it was working fine ...for a while, until one day
it stopped working and for the life of me I couldn't figure out why.

The problem with using a third-party tool is that _if_ it does go wrong, it's
very hard to debug and fix. With this in mind, I decided I would have a go at
solving the problem in a way that worked for me.

> [!WARNING]
> This is NOT a perfect solution, and in some cases it's a _poor_ solution.

In this article I'm going to show you the code I wrote to implement Go-version
switching, as well as covering some of the caveats of the approach I took. But
ultimately, this is a solution I've implemented from scratch and so I
intrinsically understand it and will understand how it works better than anyone
and will understand more easily what to do _if_ for some reason there's a bug or
scenario that I didn't account for when first creating it. This is why I provide
the above warning note. **Feel free to use my approach, or use the code as an
example from which to build your _own_ solution.**

> [!TIP]
> As an alternative, some people prefer to have a single Go version install
> (i.e. GOROOT) and then when they use the `go` binary to install other versions
> they will simply create an alias (manually) or have a Makefile accept a
> `GO_BIN` override. It's definitely a much simpler approach if you prefer that.

Let's dig in and see what we have...

## Shell Structure

OK, let's start with how I like to structure my shell files.

I have a `.zshrc` from which I then load in other shell scripts.

To avoid muddying the water I'll show a truncated version:

> [!TIP]
> If you want the full version of the code we're discussing,\
> then refer to my [dotfiles repo][dotfiles].

```shell
#!/usr/bin/zsh

function load_script {
    local path=$1
    if test -f $path; then
        source $path
    else
        echo "no $path found"
    fi
}

load_script ~/.config/zsh/tools.zsh
```

Cool, so we know we need a `tools.zsh` script. Let's take a look at the relevant
sections of that file. We'll start with the exports...

## Exports

```shell
export GOPATH="$HOME/go"
export GOROOT="$HOME/.go"
export PATH="$GOPATH/bin:$GOROOT/bin:$PATH";
```

The `GOPATH` is where we install Go CLI programs, and for our purposes it's
where we will install our different Go versions.

The `GOROOT` is where we
install our primary Go version (this is the Go version we start with and is the
version we keep up-to-date with the latest Go release).

The `PATH` is where our shell attempts to lookup executable binaries, such as
the `go` binary.

You can see we make sure `$GOPATH/bin` is checked first, then failing that it'll
check `$GOROOT/bin`, before considering any other entries in the `$PATH`.

You can probably already guess the approach I'm taking, but if not, it's this:
by having `$GOPATH/bin` as the first entry in my `$PATH`, it means I can install
my different Go version binaries there, and then create a symlink for `go` in
that same directory to point to the specific Go version binary I want to be
using.

## Installing Go for GOROOT

The first thing we need to do is identify if we have Go installed at all. We do
this by checking if there is a `go` binary file in our `$GOROOT/bin` directory.
If there isn't a file there, then we identify the latest Go version release and
download it into `$GOROOT/bin`:

```shell
if [ ! -f $GOROOT/bin/go ]; then
    mkdir -p "$GOPATH"
    mkdir -p "$GOROOT"

    GO_VERSION=$(golatest)
    OS=$(uname | tr '[:upper:]' '[:lower:]')
    ARCH=$(uname -m)
    URL="https://go.dev/dl/go${GO_VERSION}.${OS}-${ARCH}.tar.gz"
    TMP_DL="/tmp/go.tar.gz"

    echo "Downloading latest Go archive from $URL"
    curl -Lo "$TMP_DL" "$URL"

    # Extract the tar.gz file to the installation directory
    # The --strip-components=1 skips the go/ directory within the archive.
    # This ensures the ~/.go directory contains bin/ rather than ~/.go/go/bin
    echo "Extracting Go archive to $GOROOT"
    tar -C "$GOROOT" --strip-components=1 -xzf "$TMP_DL"

    # Cleanup the downloaded archive
    echo "Cleaning up Go archive from $TMP_DL"
    rm "$TMP_DL"
fi
```

> [!INFO]
> I've used `golatest` in the above code. The implementation for that is as
> follows:

```shell
alias golatest="curl -L https://github.com/golang/go/tags 2>&1 | \
    rg '/golang/go/releases/tag/go[\w.]+' -o | \
    cut -d '/' -f 6 | \
    grep -v 'rc' | \
    awk NR==1 | \
    rg '\d.+' -o"
```

## Triggering a switch on directory change

So the key part to all this is checking the current directory to see if we need
to download a different Go version. In the Zsh shell you have access to a
builtin function called `chpwd` which runs every time you change directory.
Changing directory is typically done using `cd` but it also works if you use a
tool like [ajeetdsouza/zoxide][github-z] (like I do) to quickly jump around
common project directories.

> [!TIP]
> See the Zsh [hook functions][docs-chpwd] docs.

Here is the relevant parts of our `chpwd` function:

```shell
function chpwd() {
    ls

    # figure out go version
    #
    local v=""
    if [ -e go.mod ]; then
        v=$(awk '/^go [0-9]+\.[0-9]+/ { print $2 }' go.mod)
        # go.mod isn't always going to contain a complete version (e.g. 1.20 vs 1.20.1)
        # we need a complete version for installing and symlinking.
        #
        if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            latest_patch=$(gh api repos/golang/go/tags --jq '.[].name' --paginate \
                | grep -E "^go${v}\.[0-9]+$" \
                | sed 's/^go//' \
                | sort -V \
                | tail -n 1)
            if [ -n "$latest_patch" ]; then
                v="$latest_patch"
            else
                echo "Failed to fetch the latest patch version for $v"
                go_symlink_remove # remove symlink so the PATH lookup finds the GOROOT binary.
                v="" # Ensure v is empty to prevent executing the install steps
            fi
        fi
    elif [ -e .go-version ]; then
        v="$(cat .go-version)"
    fi
    if [ -n "$v" ]; then
        # create go dependencies cache directory if it doesn't exist.
        local cache_dir="$HOME/.cache/go-deps"
        if [[ ! -d "$cache_dir" ]]; then
            mkdir -p "$HOME/.cache/go-deps"
        fi
        local cache_file="$cache_dir/go$v"

        if [[ ! -f "$cache_file" ]]; then
            go_install "$v" # installs the specified Go version
            go_symlink "$v" # ensures `go` now references the specified Go version
            go_tools # ensures we have all the tools we need for this Go version
            touch "$cache_file" # update last_modified date
        else
            go_symlink "$v" # ensures `go` now references the specified Go version

            local current_day=$(date +%Y-%m-%d)
            local last_modified_day=$(date -r "$cache_file" +%Y-%m-%d)

            # if the cache file was last modified on a different day, run the command
            if [ "$current_day" != "$last_modified_day" ]; then
                echo "updating go$v dependencies (last updated: $last_modified_day)"
                go_tools # ensures we have all the tools we need for this Go version
                touch "$cache_file" # update last_modified date
            fi
        fi

        r # reload shell so starship can display the updated go version
    fi

    # clean out any .DS_Store files
    #
    if [[ $PWD != $HOME ]]; then
        # find . -type f -name '.DS_Store' -delete
        fd '.DS_Store' --type f --hidden --absolute-path | xargs -I {} rm {}
    fi
}
```

In the above script we do the following:

- Check if the directory contains a `go.mod` or a `.go-version`.
- If there's a `go.mod` then we check if it's a 'complete' version.
- If it's not a complete version, we identify the latest patch available.
- If it's a `.go-version` then we know that will contain a full version.
- We store whatever version we find or calculate into `v`.
- We then call some custom functions and pass them `v`.

For that last bullet, the functions we call are:

- `go_install`
- `go_symlink`
- `go_tools`

The implementation for those functions are:

```shell
# go_install installs the specified version
function go_install() {
  if [ -z "$1" ]; then
        echo "Pass a Go version (e.g. 1.21.13)"
    return
  fi
    local v="$1"
    go install "golang.org/dl/go$v@latest"
    "$GOPATH/bin/go$v" download
    "$GOPATH/bin/go$v" version
}

# go_symlink is called by chpwd to allow a different go version binary to be used.
# if the specified version binary doesn't exist, we install it first.
function go_symlink() {
  if [ -z "$1" ]; then
        echo "Pass a Go version (e.g. 1.21.13)"
    return
  fi
    local v=$1
    if [ ! -f "$GOPATH/bin/go$v" ]; then
        go_install "$v"
    fi
    ln -sf "$GOPATH/bin/go$v" "$GOPATH/bin/go"
}

# go_tools installs/updates necessary Go tools.
function go_tools {
  local golangcilatest=$(curl -s "https://github.com/golangci/golangci-lint/releases" | \
    grep -o 'tag/v[0-9]\+\.[0-9]\+\.[0-9]\+' | head -n 1 | cut -d '/' -f 2)
  curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
    sh -s -- -b $(go env GOPATH)/bin "$golangcilatest"
  go install github.com/rakyll/gotest@latest
  go install github.com/mgechev/revive@latest
  go install golang.org/x/tools/gopls@latest
  go install mvdan.cc/gofumpt@latest
  go install honnef.co/go/tools/cmd/staticcheck@latest # https://github.com/dominikh/go-tools
  go install golang.org/x/vuln/cmd/govulncheck@latest
  go install github.com/go-delve/delve/cmd/dlv@latest
  go install go.uber.org/nilaway/cmd/nilaway@latest
  go install golang.org/x/tools/cmd/goimports@latest
  go install github.com/incu6us/goimports-reviser/v3@latest
  go install github.com/google/gops@latest
  go install github.com/securego/gosec/v2/cmd/gosec@latest
}
```

Finally, we run `r` which is an alias that reloads the `.zshrc` file:

```shell
alias r="source ~/.zshrc"
```

Why do we reload the shell configuration? Well, I use the [Starship] shell
prompt, and that has its own logic for determining the Go version, and now with
the above workflow it often reports the wrong Go version. But once I reload my
shell configuration it'll pick up the `go` binary that is now being symlinked to
a specific Go version.

Now, there's a performance improvement I made to the code (which you can see in
the earlier code snippet but I didn't explain):

```shell
# create go dependencies cache directory if it doesn't exist.
local cache_dir="$HOME/.cache/go-deps"
if [[ ! -d "$cache_dir" ]]; then
    mkdir -p "$HOME/.cache/go-deps"
fi
local cache_file="$cache_dir/go$v"

if [[ ! -f "$cache_file" ]]; then
    go_install "$v" # installs the specified Go version
    go_symlink "$v" # ensures `go` now references the specified Go version
    go_tools # ensures we have all the tools we need for this Go version
    touch "$cache_file" # update last_modified date
else
    go_symlink "$v" # ensures `go` now references the specified Go version

    local current_day=$(date +%Y-%m-%d)
    local last_modified_day=$(date -r "$cache_file" +%Y-%m-%d)

    # if the cache file was last modified on a different day, run the command
    if [ "$current_day" != "$last_modified_day" ]; then
        echo "updating go$v dependencies (last updated: $last_modified_day)"
        go_tools # ensures we have all the tools we need for this Go version
        touch "$cache_file" # update last_modified date
    fi
fi
```

That improvement was to check if a cache file exists for the Go version, and if
so, we'll see if the file was updated at some point in the last day. If it has
been updated already then we don't bother updating the Go tools and dependencies
for the specified Go version

We only really want to do that _once_ a day, otherwise every time you change
directory to another Go project it will unnecessarily downloads dependencies you
already have and that takes time.

[docs-chpwd]: https://zsh.sourceforge.io/Doc/Release/Functions.html#Hook-Functions
[dotfiles]: https://github.com/integralist/dotfiles
[github-g]: https://github.com/stefanmaric/g
[github-z]: https://github.com/ajeetdsouza/zoxide
[go-website]: https://go.dev/
[homebrew]: https://brew.sh/
[starship]: https://starship.rs/
