The Go programming language for Python developers
What we'll see
- A comparison between Go and Python
- Go basics
- Asynchronous programming in Go
- Webservers
- Closing thoughts
A comparison between Go and Python
| Characteristics | Python 🐍 | Go 🐹 |
|---|---|---|
| Execution | Interpreted | Compiled |
| Portability | Yes | Yes* |
| Object-Oriented | Yes | No |
| Typing | Dynamic | Static |
| Errors | Exceptions | Values |
| Memory | Garbage collector | Garbage collector |
Distribution
- Python 🐍 : Python files + dependencies (requirements.txt, pyproject.toml)
- Go 🐹 : Compiled to a static binary
Docker Image
FROM python:3.14
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY src .
CMD src/main.py
FROM golang:1.25 as builder
COPY go.mod go.sum .
RUN go build -o dist/my-binary .
FROM scratch as runner
RUN mkdir -p /app
COPY --from=builder dist/my-binary /app
RUN chmod +x /app/my-binary
ENTRYPOINT /app/my-binary
Use cases
- Python 🐍 : Webservers, Data Science, Automation, Scripting
- Go 🐹 : Servers, Tooling, DevOps, Cloud
Runtime
- Python 🐍 : Python binary that handles the execution of the program and the garbage collection.
- Go 🐹 : Runtime included at compile time as native code. It handles goroutines and garbage collection as well.
Go Basics
In Go, we structure our code in folders that we call packages. Every Go program starts with a main package.
// supermodule/main.go
package main
import "fmt"
func main() {
fmt.Println("Hey there !")
}
Those packages can be organized in modules represented by a go.mod file That is the opposite of Python.
# Initializing a Go module
$ go mod init "gitlab.com/Dhawos/supermodule"
If the module is public, anyone can pull it as a dependency his project
$ go get "gitlab.com/Dhawos/supermodule"
Each package can export its symbols. All symbols starting with an uppercase will be exported. Those starting with a lowercase won't. Unlike Python this is actually enforced by the compiler.
// Pas exporté
func echo(s string) {
fmt.Println(s)
}
// Exporté
func Echo(s string) {
fmt.Println(s)
}
Speaking of naming. Here are some naming conventions in Go:
- Packages: Lowercase single word name
- Structs, Variables, Functions, etc.. : CamelCase
Defining variables and constants
const a int = 42 // explicit constant declaration
var b int = 42 // explicit variable declaration
c := 42 // most commonly used, assignation + declaration in a single statement
Basic types
- bool
- int,uint
- float
- complex
- string
- rune
Zero values
Each type has a "zero value" that is assigned to every variable that is not explicitly initialized.
- bool = false
- int, uint = 0
- float = 0
- complex = 0
- string = ""
- rune = 0
Pointers
In Go, we have pointers but there are much safer and easier to use than in C/C++. They allow passing data by reference instead of by copying.
⚠️ Using a nil pointer = panic
Zero Value : nil
Slices
Equivalent to the list in Python
Under the hood, this is a fixed size array but that can be re-allocated to extend its capacity
Unlike lists in Python, a slice in Go can only contain a single type of elements.
mySlice := make([]int, 0, 12) // type, size, capacity
mySlice = append(mySlice, 42)
Zero Value : nil
Maps
"dict" equivalent in Python
myMap := make(map[string]int, 12) // key type , value type, capacity
Zero Value : nil
Functions
To declare a function in Go, we use the func keyword.
// takes two integers as inputs and one as output
func sum(a int, b int) int {
return a + b
}
// a function can return more than one value
func div(a int, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by 0")
} else {
return a / b, nil
}
Function as values
In Go, just like Python, functions can be used as values.
var myFunc := func(a int) int {return a}
Usual control flow statements
Here we'll find most control flow construct we are used to:
- if
- for (no while in Go)
- switch
// The "range" operator in for loops looks
// a lot like the enumerate function in Python
mySlice := []int{42, 12, 40, 2}
for i, value := range mySlice {
fmt.Println(i, value)
}
Less common control flow statements
But we also have not so usual control flow statements :
- defer: executing commands at the end of the scope
- go: launching a goroutine
- select: reading from channels
func main() {
defer func() { fmt.Println("deferred call 1") }()
defer func() { fmt.Println("deferred call 2") }()
fmt.Println("normal call")
}
Structs
Just like Python, we can define we own types in Go. In Python we talk about "classes" whereas in Go, the appropriate term would be "struct". These are two similar concepts but keep in mind they have some differences such as :
- No inheritance
- No special functions such as __init__ or operator overloading
- The method definition belongs outside of the struct definition
A struct example
type Person struct {
Name string
Surname string
Age int
}
⚠️ Fields that start with an uppercase will be exported and can be assigned outside of this package. Fields in lowercase cannot.
Defining methods
In Python, we define methods inside the class definition. Usually with a self argument.
In Go, we define methods inside the same package but outside of the struct definition. We use a receiver which is the same as the self in Python.
// p is our receiver
func (p Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
Another example
func (p Person) AgeUp() {
p.Age = p.Age + 1
}
func main() {
person := Person{
Age: 31,
}
person.AgeUp()
fmt.Println(person.Age)
}
In your opinion, what is this program output ?
- 31
- 32
Value or pointer receiver ?
func (p Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
et :
func (p *Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
How to choose ?
Value (copy):
- Small struct
- Read-only
Pointer (reference):
- Big struct
- Read-write
- ✅ In doubt: Pointer
- ✅ Stay consistent for a given struct
Interfaces
In Go we can define interfaces which are abstract types that represent a "contract".
This is a bit different from what we can find in Python or Java because of the following properties :
- Interfaces define "behaviors" and not attributes
- A struct must implement all methods of a given interface to implement it
- ‼️ Interfaces are implemented implicitly
An interface implementation example
type Vehicle interface {
Move(fuel int)
Stop()
}
type Car struct{}
func (c Car) Move(fuel int) {
fmt.Println("moving car")
}
func (c Car) Stop() {
fmt.Println("Stopping")
}
Implicit interfaces
The fact that interfaces are implemented implicitly in Go allows for a great flexibility.
It is, for example, usually a good move to declare the interface at the consumer side instead of the producer side.
Gopher mantra
The bigger the interface, the weaker the abstraction.
Handling errors in Go
⛔ No exceptions in Go
✅ Errors are treated as values
Any type can be used as an error as long as it implements the following interface :
type error interface {
Error() string
}
The most ubiquitous line of code in Go
if err != nil
Typical function in Go
// Pseudo-code
func myFunction() (string,error) {
client, err := example.NewClient()
if err != nil {
return "", fmt.Errorf("creating client: %w",err)
}
result1, err := example.DoRequest1()
if err != nil {
return "", fmt.Errorf("making request: %w",err)
}
var response myStruct
err := json.Unmarshal(result1, &response)
if err != nil {
return "", fmt.Errorf("unmarshaling response: %w",err)
}
return response.Field, nil
}
Handling specific errors
If we want to return an error that the calling function will be able to easily identify, we should use a sentinel value.
package main
import (
"errors"
"fmt"
)
var ErrDataInvalid = errors.New("data invalid")
func getData() (string, error) {
response, err := example.MakeRequest()
if err != nil {
return "", err
}
if !isValid(response) {
return "", ErrDataInvalid
}
return response
}
func main() {
data, err := getData()
if err != nil {
if err == ErrDataInvalid {
fmt.Println("invalid data")
return
} else {
panic(err)
}
}
fmt.Println(data)
}
Custom error types
If we want to use our own error types to hold more information than just text, we simply have to implement the Error interface mentioned previously.
type APIError struct {
msg string
errorCode int
}
func (e APIError) Error() string {
return fmt.Sprintf("api error %s (%d)", e.msg, e.errorCode)
}
Error chaining
Since we have no exceptions, we don't have stack traces either in Go.*
But we can "wrap" errors to add all the context needed.
err := bad()
if err != nil {
return fmt.Errorf("processing in inner: %w", err)
}
* Technically yes, but only when using panic
Find a chained error by value
Chaining errors allows us to add context BUT it breaks error comparison.
Fortunately, we can find any error buried in an error chain :
err = outerSpecificError()
if err != nil {
if errors.Is(err, ErrInvalidData) {
fmt.Println("handled invalid data error gracefully")
} else {
fmt.Printf("unknown error: %s\n", err)
}
}
Playground
Find a chained error by type
We can also compare errors in chains when looking for a specific error type.
err = outerSpecificError()
if err != nil {
if errors.As(err, &specificError) {
fmt.Println("handled invalid data error gracefully")
} else {
fmt.Printf("unknown error: %s\n", err)
}
}
Playground
Composition over inheritance
Go not being a strictly OOP language, there is no inheritance*
In cases where inheritance would be useful, we usually use composition instead.
type Person struct {
Name string
Age int
}
type Employee struct {
Person Person
Salary int
}
type Contractor struct {
Person Person
DailyRate uint64
}
* Technically, "struct embedding" can be thought of as a very limited form of inheritance but is comes with some caveats caveats
Testing
Testing comes out of the box in Go using the testing package from the standard library. Imagine we would like to test this function :
// example/sum.go
package example
func Sum(a, b int) int {
return a + b
}
Testing exported functions
To test an exported function, we can create a file with a _test.go suffix.
// example/sum_test.go
// We can suffix the name of the package with _test
// In that case we only get access to the exported
// symbols of the example package
package example_test
import (
"playground/example"
"testing"
)
func TestSum(t *testing.T) {
result := example.Sum(3, 4)
expected := 7
if result != expected {
t.Errorf("sum failed, expected %d but got %d", result, expected)
}
}
Testing internal functions
In that case, we should use the same package name in our test file.
// example/sum_test.go
// Without _test in the package name
// our test file is considered as part of the package
// and can access internal symbols
package example
import (
"testing"
)
func TestSum(t *testing.T) {
result := sum(3, 4) // The non-exported function is accessible in this case
expected := 8
if result != expected {
t.Errorf("sum failed, expected %d but got %d", result, expected)
}
}
Asynchronous programming in Go
Asynchronous
Go was developed to empower developers to easily write software that works at scale and in highly asynchronous contexts such as web or network services.
It is a language that is really high-performing in I/O bound contexts by design.
Goroutines
Writing asynchronous code is trivial at first glance, using the go keyword.
package main
import "fmt"
func func1() {
fmt.Println("Hello from func1")
}
func main() {
fmt.Println("Start func1")
go func1()
fmt.Println("End func1 ?")
}
When the main goroutine exits, all goroutines are immediately terminated.
Synchronization using wait groups
Obviously we may want to control our goroutines. One way to do that is using wait groups.
func heavyTask(id int) {
fmt.Printf("Processing task %d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := range 5 {
wg.Go(func() { heavyTask(i) })
}
wg.Wait()
}
Synchronization using channels
We can also use channels to synchronize our code. It is one of the primitives that the language offers for asynchronous programming. When in other languages we usually communicate between threads using a shared memory model. In Go the idiomatic way to do this is by sending messages through channels.
func enqeueTasks(queue chan<- Task) {
for i := range 100 {
fmt.Printf("Enqueueing task %d\n", i)
queue <- Task{ID: i}
}
close(queue)
}
func taskWorker(queue <-chan Task) {
for task := range queue {
fmt.Printf("Task%d processed\n", task.ID)
}
}
The select keyword
To execute a code path depending on which channel will answer first, we can use the select keyword.
c := make(chan string)
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
go sendMsg(c)
defer cancel()
select {
case msg := <-c:
fmt.Println(msg)
case <-ctxWithTimeout.Done():
fmt.Println("timeout")
}
Sharing memory unsafely
While it is not the main way to do things in Go. We can still share memory between goroutines. But we should be cautious when doing so.
If we try to implement this naively :
var m = map[string]int{"toto": 1}
func Read() {
for {
fmt.Println(m["toto"])
}
}
func Write() {
for {
n := rand.Intn(10)
m["toto"] = n
}
}
Sharing memory correctly
If we want to be safe for asynchronous read/writes we should use other primitives such as sync.Mutex that allow us to lock a piece of data while we are reading and/or writing to it.
type SafeMap struct {
sync.Mutex
m map[string]int
}
var safeMap = SafeMap{m: make(map[string]int)}
func Read() {
for {
safeMap.Lock()
fmt.Println(safeMap.m["toto"])
safeMap.Unlock()
}
}
func Write() {
for {
n := rand.Intn(10)
safeMap.Lock()
safeMap.m["toto"] = n
safeMap.Unlock()
}
}
Web servers
Writing an http server
package main
import (
"fmt"
"log/slog"
"net/http"
)
func healthzHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}
func main() {
const addr = "0.0.0.0:8080"
http.HandleFunc("/healthz", healthzHandler)
slog.Info("Starting server", slog.String("addr", addr))
if err := http.ListenAndServe(addr, nil); err != nil {
slog.Error("serving requests:")
}
}
Writing a middleware
We can easily write middleware withing the standard library.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received := time.Now()
next.ServeHTTP(w, r)
handled := time.Now()
duration := handled.Sub(received)
slog.Info("Request handled",
slog.Time("start", received),
slog.Time("end", handled),
slog.Duration("duration", duration),
)
})
}
Calling a middleware
http.Handle("/logged", LoggingMiddleware(http.HandlerFunc(healthzHandler)))
Grouping routes
We can easily group routes under a subpath using a ServeMux. We simply have to think about striping the prefix from the request.
usersGroup := http.NewServeMux()
usersGroup.Handle("/hello", http.HandlerFunc(helloUser))
http.Handle("/users/", http.StripPrefix("/users", usersGroup))
Context
The context package is going to be very useful in clients or server use-cases.
It allows us to share information through a chain of function calls.
func DoSomething(ctx context.Context, arg Arg) error {
client.Call(ctx,arg)
otherclient.OtherCall(ctx,arg)
}
Contexts hierarchy
In general, contexts are created from a parent context. For example :
- A command invocation in a CLI tool
- A request in a webserver
Timeouts using context
We can use it to handle timeouts when doing requests.
func slowOperationWithTimeout(ctx context.Context) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // releases resources if slowOperation completes before timeout elapses
return slowOperation(ctx)
}
Sharing data through context
One other common use-case is sharing data deep within a stack of function calls. For example, we can store some data that is associated with a request.
type traceIDkey string
var key = traceIDkey("traceID")
func TraceIDMiddleware(ctx context.Context, arg Arg) error {
traceID := generateTraceID()
ctx := context.WithValue(ctx, key, traceID)
}
func processRequest(ctx context.Context) {
err := doSomething()
if err != nil {
if traceID := ctx.Value(key); v != nil {
tracing.Send(string(traceID))
return
}
}
fmt.Println("request processed")
}
Closing thoughts
We just made a small tour (non-exhaustive) of what the Go language has to offer.
Here are some subjects that we skipped but can be worth looking at
- Generics
- The reflect package
- GC internals
- The race detector
- CGO
- WASM
I would like to share some ideas if you want to dig into some use-cases particularly suited to Go.
Kubernetes development
Kubernetes is developed in Go and therefore has all the client libraries you would need to interact with Kubernetes and its API. You can use the same client libraries that those used in the official kubectl client for example.
This is naturally a very good choice for developing controllers. There are also frameworks that makes this somewhat easier such as kubebuilder.
CLI tools
Since Go's build output is usually a single statically linked binary (that can be compiled to other platforms as well) it is very well suited to distribute CLI tools.
To develop such a tool you can use the Cobra library which streamlines the experience of building a CLI. That library is famously used in :
- kubectl
- Github CLI
- Docker
Digging deeper
If you're interested in learning Go. Here are some exercises you can challenge yourself with to learn the ins and outs of the language and its standard library.
- Building your own GNU commands(cat, ls, grep etc...)
- Building your own simplified Redis
- Advent of code
Some nice projects to build after that
- A server side rendered (SSR) web app using html/template or templ
- Slack/Discord bot
- Kubernetes Controller with Kubebuilder
Resources
Have fun !