DevOps with Dhawos

Cloud, infrastructure and programming

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

Playground

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

Playground

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

Playground

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

Playground

Maps

"dict" equivalent in Python

        
          myMap := make(map[string]int, 12) // key type , value type, capacity
        
      

Zero Value : nil

Playground

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
          }
        
      

Playground

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

Playground

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")
          }
        
      

Playground

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.

Playground

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

Playground

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

Playground

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")
          }
        
      

Playground

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

Playground

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

Playground

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()
          }
        
      

Playground

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

Playground

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")
          }
        
      

Playground

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
            }
          }
        
      

Playground

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()
            }
          }
        
      

Playground

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
When a context is canceled, all its children are canceled with it. This allows for easy propagation of signals that an action should be aborted through function calls.

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

Resources

Have fun !