If you work on muliple Go 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 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.

#!/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

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:

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:

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 (like I do) to quickly jump around common project directories.

💡 TIP

See the Zsh hook functions docs.

Here is the relevant parts of our chpwd function:

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:

# 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:

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):

# 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.