Go vs. Python: an introduction to Go for senior (Python) developers

In this article I compare the programming languages Go vs. Python. I highlight 3 similarities and 12 differences. This comparison is written for senior developers, but it is not exclusively limited to Python developers: everyone with a OOP-language background (like Java or C++) will benefit. I also provide tips regarding learning resources you should start with.

Introduction

I recently learned the Go programming language, to develop the directory-checksum CLI tool, which helps you debug issues with the Docker image-layer cache (see this article). When starting the learning experience, I was already familiar with other object-oriented programming (OOP) languages, such as Java, Kotlin, C++, and (most of all) Python, having developed software with these languages for more than 10 years.

This article provides a brief introduction to Go(lang). This piece is targeted to senior (Python) software developers who already know what loops or other control structures are. A senior developer only cares about the similarities and differences between Go and the language they already know, which I assume is some “typical” OOP language like Python.

Similarities to Python

While there are very many differences between Python and Go, let’s first look at their similarities:

  • Like Python, Go comes with “all batteries included”, meaning that it has a large standard library that covers most functionality you need, e.g. to handle files, concurrency, CLI option parsing, making syscalls, or writing unit tests.
  • Go has automatic memory management. Even though Go supports pointers, you do not have to “delete” objects yourself, like you would have to do with C/C++. The Go runtime does garbage collection automagically for you.
  • Go comes with a huge ecosystem, considering libraries, frameworks and other tooling, e.g. GitHub Actions, or IDEs.

Differences to Python

Let’s look at the 12 biggest differenes between Go and Python.

Go is a compiled language

Go is a compiled language that produces static binaries that work on various Linux distros. You may need to set the CGO_ENABLED environment variable to "0" before compiling your application (go build), to get truly static binaries that do not rely on a specific C library being already present on the target system.

Coming from Python, I found the compilation times during development slightly annoying. But, on average, Go does compile faster than other languages, such as C++ or Java.

Better performance

Go has much better run-time performance than interpreted languages, such as Python. This is especially true for mathematical calculations, and demonstrated by benchmarks such as this one. Of course, in case of Python, performance is often not that much of an issue if the actual mathematical calculations happen inside native libraries, such as Pandas or numpy, and your Python code is mostly glue logic.

Easier deployment and cross-compilation

Go binaries are exceptionally easy to deploy. The compiled static binary is small in size, compared to Python, where you need something like PyInstaller or PyOxidizer (see my previous article) to create a static binary. Furthermore, PyInstaller binaries are actually not fully static and therefore do not work on every Linux distro by default, see details here.

Luckily, Go ships with a built-in cross-compiler, so you can easily build binaries for other operating systems or CPU models.

Go is statically-typed

Go is a statically-typed language. This means that in Go code, you must declare type signatures for all your variables and functions. Also, the type of a variable cannot change over time. Python, OTOH, is a dynamically-typed language. As I’ve discussed here, you can (and should) add static type hints to your Python code, but they still do not give you the same type safety as Go or C.

Go supports call by reference (via pointers)

Languages such as C, C++ or C# allow you to declare arguments as reference arguments (sometimes referred to as “in-out” arguments) that have two advantages:

  • A function can modify the values of arguments from inside the function, such that these changes are visible from outside the function.
  • You avoid the performance penalty of call-by-value, where the argument’s value is copied to the function.

Go works like C/C++/C# in this regard, using pointers to hand over references of primitives (e.g. ints) or complex structs. The operands * (read from a pointer, or write to a pointer) and & (get the pointer-address of a variable) work just like in C. However, Go has no pointer arithmetic, which avoids many hard-to-debug problems. And, unlike in C or C++, it is safe to return the address of a local “stack” variable created inside a function.

Let’s look at a simple example of a swap method that illustrates the usage of pointers:


package main import "fmt" func swap(a *int, b *int) { *b, *a = *a, *b } func main() { a := 5 b := 10 swap(&a, &b) fmt.Printf("a: %d, b: %d", a, b) // prints: a: 10, b: 5 }
Code language: Go (go)

Python does not let you pass certain variables by reference! Python decides for you which types are passed by reference (e.g. dicts, lists, complex objects), and which ones are passed by value (e.g. strings, floats, ints or other primitives).

Go does not have classes or “class implements interface” syntax

Not only does Go not have the concept of classes, it also does not let you explicitly define a relationship between a class and an interface that it “implements”.

Typical OOP languages offer the concepts of classes, interfaces, and explicitly connecting them (e.g. writing class MyImpl implements SomeInterface). Python also works this way, although, technically, you have to emulate interfaces with abstract base classes, which your implementation class extends from.

In Go, there are no classes! To replace classes, you define structs, as they are known from C or C++. You then extend your struct with methods. To define a constructor for your struct, the best practice is to write a function (not method) named NewSomething (replacing Something with the name of your struct) that creates a (possibly pre-initialized) struct instance and returns it (or the pointer to it). Let’s take a look at an example:

package main import "fmt" // Rectangle declares our Rectangle "class" as struct type Rectangle struct { width int height int } // We extend the Rectangle struct by an area() METHOD // "r" is called the "receiver", and it is common to implement methods for POINTER-receivers for 2 reasons: // 1) be able to CHANGE the values of the fields of the struct instance (not necessary in this example) // 2) performance reason (avoid COPYING the struct) func (r *Rectangle) area() int { return r.width * r.height } // String implements the "toString()" method known from other programming languages func (r Rectangle) String() string { return fmt.Sprintf("Rectangle{width=%d, height: %d)", r.width, r.height) } // Demonstrates various ways of initializing a Rectangle func main() { r := Rectangle{} // initializes a Rectangle with zero-ish default values r = Rectangle{width: 50, height: 50} rp := NewRectangle(10) // Call the custom constructor defined below fmt.Printf("%v %v \n", r, rp) // Print the instances, which uses the String() method defined above } // NewRectangle is a custom constructor FUNCTION that creates an equilateral(!) Rectangle // (returning a pointer for performance reasons, to avoid copying the Rectangle) func NewRectangle(widthHeight int) *Rectangle { return &Rectangle{width: widthHeight, height: widthHeight} }
Code language: Go (go)

Go does not offer any visibility modifiers (such as public or private), but the convention in Go is that capitalized methods / variables / functions / structs / … (e.g. Rectangle) defined within a package (as in “Java/Python package”) are “exported” (and thus accessible) to other packages (like “public”), while those that are not capitalized (e.g. area()) are inaccessible / private.

Go does support interfaces, as the following example demonstrates.

type Area interface { area() int }
Code language: Go (go)

However, Go does not have an implements keyword. I’m still fuzzy about the actual reason and benefits of Go’s approach. Some discussions (like this one) suggest that this reduces the complexity and required level of communication between the developers who define the interface and the ones implementing it (assuming that these are different people).

Anyways, at first I found the main disadvantage of this approach completely appalling: you do not know whether your implementation conforms to an interface. But then I understood that Go’s interfaces and structs work like (Python’s) duck typing, with the difference that it can be statically verified by the compiler at usage-time, as explained here. You simply put a small verification snippet somewhere, e.g. into your unit test suite, and have peace of mind regarding type safety. The compiler would complain if your implementation was not compliant. For our above example:

var _ Area = &Rectangle{} // Create an unused variable of type Area and assign a Rectangle to it var _ Area = (*Rectangle)(nil) // Like above, but for a nil-pointer
Code language: Go (go)

Go (officially) does not support sub-classing / inheritance

Go has a rather quirky understanding of inheritance / sub-classing: Go officially states here that it does not support inheritance, but instead employs the concept of embedding types within structs or interfaces.

However, when you then look at their examples, it becomes obvious that embedding interfaces into interfaces is exactly like letting one interface inherit from one (or more) other interfaces. For structs the story is slightly different: structs really do not support inheritance, but instead they are composed/embedded (as in: “composition over inheritance”). You notice this when you try to create the struct, since you need to explicitly provide a value for each embedded field. However, Go implicitly generates all methods of the embedded interface(s) on the struct for you, saving you from having to write loads of boilerplate code.

Go does not have exceptions

Go does not offer exception classes / class hierarchies, nor does it support try-catch statements. Instead, errors are normal values (see this blog post), which you manually check against a nil value (nil is Go’s terminology for null). In Go, it is a very typical pattern that your function or method returns two values:

  • The value it is supposed to return (if an error occurred, the return value is something “zero-ish”, e.g. "", 0, or nil)
  • An error value object (which is nil for the happy path)

Here is a simplified example of the directory-checksum code base that hashes a file with SHA-1:

// computeFileChecksum computes the SHA-1 digest of the file located at absoluteFilePath and returns it as // string that represents the digest with hexadecimal notation func computeFileChecksum(absoluteFilePath string) (string, error) { f, err := os.Open(absoluteFilePath) if err != nil { // you need to explicitly and manually check for errors and return them as object return "", err } defer f.Close() // "defer" will be explained in the next section h := sha1.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil }
Code language: Go (go)

The pattern of writing something like returnedValue, err := someFunc(someArg) is used often in Go, not just for errors.

Let’s take a look at retrieving a value from a map (as in: dictionary) that does not contain the requested key:

m := map[int]string{3: "three", 4: "four"} // pre-initialize a map that maps from int to string x, ok := m[1] // try to access a map entry for a key which does not exist if !ok { // ok will be false, because 1 is not in the map fmt.Println("1 does not exist in the map") } else { fmt.Print(x) // x would be the value, if it were in the map } // Do NOT write the following (which lacks the second "ok" parameter): it is valid Go syntax, // but your code will "panic" / crash / segfault if the element cannot be found v := m[1]
Code language: Go (go)

Similarly, if you want to determine the concrete type (e.g. a specific struct) of an interface-object, you would use the so-called type assertions feature (which I would have called type casting). The syntax is v, ok = x.(T) where x is a variable that has some interface type, and T is some concrete type. ok is a boolean that tells you whether the assertion actually worked, and if so, the type of v is T and it is not nil (so you can call its methods or fields).

What bugged me most about this “errors are values” approach, is the lack of stack traces. This makes it very difficult for a developer to pinpoint the location of a problem. In fact, treating errors as values (like back in the old C days) has a number of problems, which the Go maintainers also realized already a few years ago, see here and here. There is hope that things will improve in Go v2.x, maybe…

In the meantime, a plethora of helper Go modules have emerged, which create new types that implement Go’s error interface. They provide structs that wrap an error object, adding extra information (such as stack traces), or allowing you to build error hierarchies. Examples are go-errors/errors (which I used and can recommend), spacemonkeygo/errors, juju/errors or go-errgo/errgo. Pick your poison.

To avoid that you “forget” checking for errors, you can use static analysis tools such as errcheck or golangci-lint.

Go uses defer instead of try-finally

As I just explained above, with the lack of exceptions, there is no need for try-catch blocks. Consequently, Go does not support try-finally blocks, either. Instead, it uses defer <method-call> statements instead, as shown in the above example.

defer works like try-finally blocks you know from other languages – the execution of the code that you defer is delayed until after the return statement has finished. The authors of Go deem defer to be a “cleaner” version of try-finally: it makes the code more readable, because defer avoids the need for extra code indentation (which the try/finally block does require) and it allows you to place the finalization calls right at those lines where you initialized/opened the corresponding resource (e.g. a file handle).

Go has decentralized dependency management

Dependency management in Go works very differently compared to Python or other languages: when using Python’s built-in dependency management tool, pip, you declare the name and (pinned) version of your dependencies in a requirements.txt file. A command such as pip install -r requirements.txt downloads the dependencies from PyPi, Python’s centralized package repository. The bundled package (stored on PyPi) may contain Python code, compiled binary code, or both.

With Go, however, authors publish Go modules. Go modules are roughly equivalent to Python packages (note that Go also has packages, which are simply sub-folders inside a Go module, so they are like Python sub-packages). When you use a Go module, it is typically downloaded as source code (and compiled on your machine). Go lazily compiles only those files on-demand that your own code actually imports. Most Go modules are hosted on GitHub repositories, but they could be hosted in any Git repository, meaning that Go uses a decentralized rather than centralized approach. Consequently, import statements in Go typically look like this:
import "github.com/some-user-or-org/some-go-module/some-go-package-that-you-need"

Today, Go modules are typically mirrored on a Proxy (see here for details), such as the public Golang proxy proxy.golang.org. This serves two purposes:

  • The proxy provides a web interface at pkg.go.dev that lets you search for modules and read their documentation as nicely formatted HTML. For this to work, a module’s developer must run a command such as
    GOPROXY=proxy.golang.org go list -m example.com/mymodule@v0.1.0
    to push their module to the proxy, so that it can index the module.
  • It improves the module download time, because your local Go runtime can download a tarball of the source code from proxy.golang.org instead of doing a (much slower) git clone of the module’s Git repository.

The equivalent to Python’s requirements.txt is Go’s go.mod file. It defines the name of your module, the minimum expected Go (compiler/runtime) version, and all other Go modules you use as dependencies. The list of dependencies in the go.mod file is exhaustive, meaning that it also includes indirect/transitive dependencies and their pinned versions. Go also creates a go.sum file that contains checksums of the modules you depend on, used to verify the integrity of downloaded modules.

As the developer of a Go module, you often use the command go mod tidy. It ensures that all used imports in your code are also present in go.mod and go.sum, while also deleting unused imports from your code. If you merely use a Go module, go mod download is for you: it does not modify the go.mod file, but only downloads all dependencies listed in it, to your local Go modules cache.

Go has excellent support for concurrency

Go has excellent support for concurrent processing, using Goroutines (tutorial). Goroutines are a bit like threads, but much more efficient: Go uses its own internal scheduler (not the scheduler of the kernel of the operating system) to distribute (a potentially huge number of) Goroutines among a small fixed number of OS-kernel-threads (managed by Go). You control the number of kernel threads via GOMAXPROCS, which defaults to the number of cores your CPU has.

From my understanding, using Goroutines is similar to putting tasks into a ThreadPoolExecutor – a concept well-known in many OOP languages. However, Goroutines do not require any explicit code that starts or shuts down a ThreadPoolExecutor instance. Also, Goroutines support preemption, similar to “asyncio” implementations known from languages like Python: Goroutines that are blocked by I/O-related methods get suspended by Go’s scheduler, which then schedules other (waiting) Goroutines in the meantime. This is something a pure ThreadPoolExecutor implementation would not do.

To let a Goroutine return data to its caller, or to share data between active Goroutines, Go supports various synchronization primitives (e.g. mutexes) and async communication channels, which are similar to Python’s asynchronous iterable generators.

In comparison, Python’s support for concurrency is abysmal. While you can have multiple threads, they won’t make proper use of your CPU cores (due to the Global Interpreter Lock). If you want to use multiple CPU cores, you must fork extra processes.

Go comes with built-in, opinionated formatting

The Go run-time comes with a built-in formatting tool called Gofmt. In contrast, other languages require extra packages for this, e.g. pylint for Python. Like certain packages (e.g. autopep8 or black), Go’s formatter is opinionated, meaning that it cannot be configured. In practice, you typically won’t need to run the Go formatter manually (go fmt), because IDEs such as VS Code or Goland typically run it whenever you save a file.

Learning resources

Approaching learning new programming languages

When learning a new programming language, I learnt over time that the following three aspects are key:

  • Start with why: what is your motivation to learn the language? Do you know where its strengths and weaknesses are? Do you have a (side) project to which you can immediately apply your gained knowledge, and which would profit from the strengths of the language?
  • Find up-to-date, concise resources that only teach the differences (and similarities) of the new programming language, compared to languages you already know. Books are usually a bad resource, because they are often already out of date and too verbose. On average, I fail to find good concise resources, and I hope that this article is of some help to you (until it becomes out of date).
  • Be aware that you need to learn the grammar and syntax of the language (using it “idiomatically”), and its surrounding ecosystem. This includes aspects such as:
    • The best third-party frameworks for common tasks (beyond what the standard library offers), such CLI parsing, web/API development, or (unit) testing.
    • Deployment aspects: how does your application make it into production? A static binary (like Go) works completely differently than a Spring Boot jar file (that needs a JRE), or a war file (that needs an Java EE server). You need to understand how related tasks, such as compiling/transpiling/packaging/configuring/etc. works.

Useful learning resources for Golang

With Go, I was pleasantly surprised that the official Go Tour is a well-designed step-by-step tutorial for developers. You should definitely take the tour first thing, right along with the How to Write Go Code tutorial.

The next good read is the Effective Go section of Go’s official documentation. It teaches idiomatic Go and goes into many details of Go’s syntax.

In general, I also think many of the articles listed on the overview page of Go’s user manual can be useful. Skimming Go’s FAQ should also broaden your perspective.

Finally, if you need a condensed cheat sheet, I highly recommend the Go page of Learn X in Y minutes (which has similar offerings for many other programming languages).

Conclusion

Go is a very strong competitor to other programming languages. It has a large ecosystem, executes and compiles quickly, and is easy to deploy. With directory-checksum, I only wrote a small CLI with Go, but the experience was mostly pleasant, with some exceptions (such as the lack of exceptions, pun intended).

A lot of Go’s syntax is, unfortunately, hard to get used to. For starters, why did the authors of Go design a modern programming language and use the following syntax to define a method signature:
func (r Rectangle) DoSomething(input []int, moreInput map[int][]string) (string, error) {…}
when they could instead have used a syntax that is much easier to read (TypeScript-style), like
Rectangle::DoSomething(input: int[], moreInput: map<int, string[]>): [string, error] {…}
Maybe my criticism is not justified, maybe I’m lacking background in other languages where such syntax is common. For folks with a background in “traditional” OOP languages (Java, C#, Python, …) the Golang syntax will often seem strange.

Anyways, if you work a lot in the cloud ecosystem, knowing Go is beneficial. You can write your own, easily-deployable CLI tools in Go, to replace complex, brittle bash scripts. Or you can participate in the ecosystem of existing tools, diagnosing and fixing bugs, or even extending them. It feels like 75% of cloud-related tools (including Kubernetes) are written in Go.

What has your experience been with Go? Did you have a similar learning experience? Please let me know in the comments!

Leave a Comment