Skip to main content

Dependency Management in Go

SoftEng || InfoSec

By Manel Montilla

Dependency management in any language or ecosystem is more nuanced that it could initially look. Given that Go's approach to dependency management has some fundamental differences with other languages, I think it's worth explaining the fundamentals and giving some guidelines for the basic operations. During the last few years the dependency management story in Go has changed a lot, this post covers the Go versions 1.17 or higher.

The post is divided in 3 parts:

Basic concepts

In Go the basic unit of work for publishing and versioning code is called module. Notice that a package is different from a module. A package is: a collection of source files in the same directory that are compiled together, while a module is: a collection of packages that are released, versioned, and distributed together.

Go modules are identified by their name and a version, for instance: example.com/moda@v0.0.0. The version of a module must follow semantinc versioning. Different major versions of the same module are considered different modules, so you can use multiple major versions of the same module at the same time.

A module can use other modules, those modules can, in turn, use other modules and so on.If a module A uses another module B, we say the module A depends on the module B.

For a given module, the modules it depends on, that is the modules it directly uses, are called direct dependencies, in turn, the modules that those direct dependencies depend on are called transitive dependencies or indirect dependencies.

The dependencies used by a given module are listed in its go.mod file. The go.mod file is modified by running commands provided by the go tool, for example: go get example.com/moda@v0.0.0.

The go.sum file contains the crytographic hashes of the direct and indirect dependencies of a module. This information is used by the Go tool to detect if any of those dependencies has been tampered with.

Guideline

Fundamentals

In order to understand how Go manages dependencies is important to keep in mind the following main ideas:

  1. As stated by Russ Cox, the Go modules system aims to produce high-fidelity builds: in which the dependencies a user builds are as close as possible to the ones the author developed against.

  2. When you add a dependency to your module, you specify the minimum version in a fixed major version that your module can use. For instance, if you add a dependency with the version v1.0.1, what you are saying to the Go tool is: my module prefers to use the version v1.0.1 and that's the version my module is tested with, but any version with a greater or equal patch to 1 or greater or equal minor to 0 is valid.

Given the points 1 and 2, and unless you explicitly force a concrete version, the Go commands will always select the minimum valid version of any indirect dependency.

About this guideline

Drawing the dependencies of a module as a graph is useful for visualizing how the commands of the Go tool affect those dependencies. This guide uses graphs similar the following one to illustrate these effects:

example module deps graph

The vertices of the graph represent the versions of the possible direct and indirect dependencies of the module modroot.

The red arrows represent the direct dependencies used by the modroot,

The blue arrows represent the indirect (aka transitive) dependencies.

The black arrows of the graph represent which version of the modc is being used by a concrete version of the modules moda and modb.

All the commands in the examples affect the dependencies of the module modroot.

The examples assume that if we add a dependency, the dependency is actually used somewhere in the code and, if we remove a dependency, there is no code that uses that dependency anymore. This is important because the command go mod tidy, executed at the end of each example to keep the go.mod and go.sum files consistent, takes into account if a dependency is actually used in the code when updating those files.

Basic Commands

  • Initialize a new module

    • Command: go mod init module-name

    • Example:

       go mod init example.com/modroot
      
    • Notes:

      This command will just create a go.mod file that will be edited by the go tool when adding, removing or updating dependencies.

  • Adding a direct dependency

    • Command: go get package@version

    • Options:

      • if no @version is specified the tool will select the newest version.
    • Example

      Before:

      before

      Commands:

       go get example.com/moda@v1.0.0
       go get example.com/modb@v1.0.0
       go mod tidy
      

      After:

      after

  • Removing a direct dependency

    • Command: go get package@none

    • Example

      Before:

      before

      Commands:

      go get example.com/moda@none
      go mod tidy
      

      After:

      after

  • Updating a direct dependency

    • Command: go get package@version

    • Options:

      • If no version is provided the command will update the dependency to the last version in the same major version.
    • Example

      Before:

      before

      Commands:

      go get example.com/moda@v1.0.1
      go mod tidy
      

      After:

      after

  • Force update direct and indirect dependencies

    • Command: go get -u package@version

    • Options: If no version is provided the command will update the direct dependency to the last version in the same major version. But notice that the command will always update the indirect dependencies.

    • Example

      Before:

      before

      Commands:

      go get -u example.com/modb
      go mod tidy
      

      After:

      after

    • Notes:

      Notice that this command breaks the idea of "high-fidelity" builds, as your program could end up using indirect dependencies that no direct dependency is actually using, therefore, my advice is to not use it unless you absolutely need to.