Python vs Go

Published 2026-03-14


2025 was the year I moved from Python to Go. I was developing an internal automation tool for work in Python. As I was reaching 10,000 lines of code I started to hit a few roadblocks in mistakes I had made. I had also found some things I didn't like about the language. So I started looking elsewhere and found Go. In this article I will explain what got me away from Python and into Go.

Error handling

I often found myself struggling with how to handle things going wrong in my code. Python handle errors through raising Exceptions and wrapping the called function inside a try/except block. Go takes another approach. Any function whose execution can fail will return an error as the last return value. If nothing went wrong, the error value is nil. An example of this difference can be found when reading files, so let's start our comparison there.

Python:

try:    file = open("input.txt")except OSError as e:    print(e) # [Errno 2] No such file or directory: 'input.txt'

This is the Python way. Since opening the file may fail, we wrap it in a try/except block and catch relevant exceptions. How did I know to check for OSErrors? I had to look it up in the official docs. The function itself doesn't signal that anything can go wrong. Nor will your IDE tell you that exceptions may be raised by the function you called. You have to know it, look it up online or explore the source code.

Go:

file, err := os.Open("input.txt")if err != nil {    fmt.Println(err) // open input.txt: no such file or directory}

Here the function clearly signal that something can go wrong when opening a file because the function always returns an error value. The file may not exist, or we might not have permission to open it. If err is nil, no error occurred. As a developer, I'm forced to acknowledge that an error may occur. I may still choose to ignore the error, but I have to actively make that choice. I much prefer this explicit statement of an error over Python's use of exceptions.

Before finding out how Go return explicit errors, I had started implementing a similar setup in my own project. I standardized my own functions so that they always returned a Result object that looked something like this:

from dataclasses import dataclass @dataclassclass Result:    success: bool    message: str    data: str | list | dict def main():    result = do_thing()    if not result.success:        result.message = "do thing: " + result.message        exit(1) def do_thing() -> Result:    result = read_file("input.txt")    if not result.success:        result.message = "read file: " + result.message        return result    [...] def read_file(file_name: string) -> Result:    try:        with open(file_name) as file:            content = file.read()    except OSError as e:        return Result(success=false, message=e)    return Result(success=true, data=content)

The Result class got the job done. If something went wrong, I added some context before returning the result up the stack. If nothing went wrong and some data was returned, do_thing() would fetch it from result.data. This felt better than using exceptions because it was easy to see the result being passed up the stack.

However, one big problem with this approach lied in the wide definition of the data variable. It could contain pretty much anything. Trying to figure out its type for every called function caused headaches as the number of functions and the variation of possible return values increased. In hindsight I should have returned data in a separate variable and just made Result into something simpler that was only for error handling.

Let's look at how Go natively does almost the same thing:

func main() {    content, err := readFile("input.txt")    if err != nil {        fmt.Printf("read file: %v\n", err)        os.Exit(1)    }    fmt.Printf("%d bytes in file\n", len(content))} func readFile(fileName string) (content string, err error) {    file, err := os.Open(fileName)    if err != nil {        return "", fmt.Errorf("open file: %w", err)    }    defer file.Close()     contentAsBytes, err := io.ReadAll(file)    if err != nil {        return "", fmt.Errorf("get file content: %w", err)    }    content = string(contentAsBytes)     return content, nil}

Because os.Open() and io.ReadAll() both return an error, it's natural for the caller readFile() to also return an error. When an error occurs, readFile() adds extra context via fmt.Errorf() and then sends the error up the call stack. When main() gets the error it adds context of its own before printing it to stdout and exiting the program. Data is returned in a separate function so it's easy to annotate its type correctly.

Example error message:

read file: open file: open input.txt: permission denied|          |          ||          |          context from os.Open()|          context from readFile()context from main()

It's funny how close I got to making the same setup in my Python project. This was definitely the first big thing that opened my eyes to Go.

By the way, I couldn't help myself from showing off the Go named return values feature which allow you to name each return value, not just display its type. It's optional to use and should only be used in places where it gives helpful information. I think it makes sense in this example as it clarifies what data the returned string from readFile() will contain. All information you need is provided by the function signature.

Defer()

This is another cool Go feature. I actually snuck it into the last code snippet: defer file.Close(). Defer tells the program to "run this when the function returns". In this example, it means that the file we just opened is guaranteed to close when the functions returns. The defer is executed regardless of whether the function returned early due to an error or at the end of the function.

In Python we get the same file-closing behavior by using with open(), but that only applies to code inside the with code block. It also tends to add extra indentation to the code, especially when wrapped inside a try/except block. Defer is just... neat.

Standard Library

Go has a well rounded standard library, reducing the need for third-party packages. I liked building web-servers with Python and Flask was often my go-to solution thanks to its simplicity. But I still had to choose between Flask, Django, FastAPI or some other alternative. Because even though there is a http-server in Python std lib, it is not for "production" use. You want some framework on top.

This is not the case with Go. The net/http standard library is more than enough to handle your web server needs. This might not come as a surprise as Google is a leading web services company. But you don't have to spend the time to learn whether to use Echo/Gin/Gorilla/Fiber or other Go web frameworks. The std lib is good enough on its own.

The standard library also has a backwards compability promise. Any Go 1.X version is guaranteed to be backwards compatible with any previous 1.X version. If you write code for the current Go version (1.26), leave it untouched for 10 years and then upgrade to Go 1.46, your code should run just fine. That promise is hard for any third-party package maintainer to give. That third-party package you use may not even be around 10 years from now.

The stronger the std lib is for a specific language, the safer that language is. It means you are less likely to use third-party packages that don't undergo the same scrutiny and audits that the std lib will ultimately endure. Each external package is additional risk.

If you want to build an API backend in Python, the go-to stack today is FastAPI + Pydantic. FastAPI handle the HTTP server and Pydantic is used to transform JSON payloads into structured data and back into JSON. These two tools are developed and maintained by external companies. Go can do the same thing with the standard library. Use the net/http package for the HTTP server, use encoding/json for parsing JSON into structs or other data types.

This is what that would look like:

import "encoding/json" type Router struct {	Name         string `json:"name"`	SerialNumber string `json:"serial-number"`} func main() {	routers := []Router{		{Name: "R1", SerialNumber: "aabbcc"},		{Name: "R2", SerialNumber: "bbccdd"},	} 	// Marshal 'routers' data structure into JSON format	jsonBytes, err := json.Marshal(routers)	if err != nil {		fmt.Println("marshal routers into JSON:", err)		os.Exit(1)	}	fmt.Println(string(jsonBytes)) 	// Unmarshal JSON data into slice-of-routers data structure	var routersFromJSON []Router	if err = json.Unmarshal(jsonBytes, &routersFromJSON); err != nil {		fmt.Println("unmarshal JSON into a slice of routers:", err)		os.Exit(1)	}	fmt.Println(routersFromJSON)}

The line routers := []Router{} creates a slice (list in Python-speak) of Router structs. The := means that we are creating a new variable. This data structure is then marshalled into the JSON format, which Golang stores as a slice of bytes. To print the JSON data in a human-readable format, we have to convert it to a string. Just to show how, we end by converting the JSON-formatted byte-slice into another slice-of-routers data structure.

This is the jsonBytes output in string format:

[  {"name": "R1", "serial-number": "aabbcc"},  {"name": "R2", "serial-number": "bbccdd"}]

This example show how powerful struct tags are for converting a Go struct into JSON and back again. This is made possible by the json:"serial-number" tags, allowing us to populate each JSON key into the correct struct field, even though the field/key names don't fully match. As far as I know, Python can not do this out of the box. This is why big third-party packages like Pydantic exist for Python. Golang got this in its standard library.

Enforced type checking

Where Go is a statically typed language, Python is dynamically typed. In Python a variable can start as a string, turn into an int and end up as a list of dictionaries. This freedom is both a blessing and a curse. You can easily throw a quick script together, but maintaining it becomes harder as code become harder to reason about. This is why type annotations have gotten a big upswing in Python. It tells your IDE what type the variable should be, allowing the IDE to warn you if it turns into something else. However, these annotations are ignored by the interpreter, so if you ignore your IDE you can still blow up your program at runtime.

With Go, the type of a variable is set when created. Once set, the variable type cannot change, because the program won't compile. While it may feel limiting at first, this turn into a big helper as your code grows. If you do something wrong, the program will not compile. Changing a struct field is a common issue for me where I forget to change all places in the code that interact with that struct type.
I've also had a few cases where I changed the number of arguments that was passed into a function but I forgot to update all places where that function was called. The Go compiler caught that. Python can't really catch these mistakes, atleast not the tools I used. That being said, Go compiler is not perfect. It's still possible to blow up your program at runtime.

Performance

Python is an interpreted language. The python3 binary is the interpreter, which must be installed on your machine to execute the Python file. The fact that the code has to be interpreted at runtime makes it slow to run.

Another drawback is the dynamic typing. Before an action can be performed the interpreter first has to check the type of the variable to know what action to perform. For example, a + b behave differently if a and b are integers vs strings (addition and concatenation, respectively). This constant runtime type checking slows Python down.

Go is a compiled language. A Go compiler has to turn the program into a binary before it can run. Once compiled, any computer can run the binary without having Go is installed. The compiler does its job very quickly and produces efficient fast-running code. I don't know more about the compiler than this, so let's quickly move on to other topics.

Both languages are garbage-collected. A garbage collector (GC) help free memory used by unused variables. Python uses a reference-counting and generation-based garbage collector. A variable whose reference-counter resets to 0 can be cleaned up. Variables with cyclic references (a -> b -> a) are handled by the generation-based GC.

Go versions before 1.26 used a mark-and-sweep GC. 1.26 introduced their green tea GC. It's more efficient thanks to how it traverses memory more efficiently during the mark phase. The less CPU-time that the GC has to spend cleaning up, the more time is available to run the program itself.

Most comparisons I have seen of the two languages usually see a 10x improvement in performance when switching from Python to Go. This means you need ten Python instances to do the same job that a single equivalent Go instance can do. Here are some real-world Python -> Go migration examples I have collected over time:

Concurrency

Python has multiple ways to run code concurrently. A program can be multi-threaded, multi-processed or running an asynchronous event-loop. Async means that a task that is blocked waiting for something else, typically IO, can be paused so that some other task can use the CPU in the meantime. Asynchronous code has been growing in popularity in Python, but with it comes additional complexity.

This is one reason for why the asynchronous FastAPI a much faster web framework than the synchronous Flask. If you have a HTTP endpoint that receives a request and then makes a call to a DB before returning a response, FastAPI can switch between multiple active requests while Flask is stuck waiting for the DB response for the first HTTP request before it can serve the next. Note that async is single-threaded, although the thread can utilize its CPU-core more efficiently if a lot of what the program does is wait for IO.

The problem with async is that all code has to be async. If your async function happens to call a synchronous block of code, your FastAPI app now halts and blocks just like Flask would. This makes it important to figure out which libraries or packages work with your setup. Or if you are a library/package maintainer, you may have to provide both sync and async versions of the same functions.

Python could handle each HTTP request in its own OS thread, but creating threads is slow and relative expensive, so this is usually not beneficial. Also, Python has historically had a global interpreter lock (GIL) that prevents more than one thread from running at the same time. Python is working on providing a GIL-free interpreter, but at the time of writing it's still an experimental feature.

Here's a famous blog post discussing sync/async: What color is your function?

Go doesn't have this problem thanks to its goroutines. These are green threads that are managed by the Go runtime, making them much more lightweight than OS threads. Where an OS-thread typically allocate 1-8 MB of memory on initialization, a goroutine start with a 2 KB allocation.
When the Go binary starts, it figures out how many CPU-cores it has access to. It then spawns one OS-thread per CPU-core that stay alive for the lifetime of the program. The runtime can then schedule (pause, swap, run) goroutines to the OS-threads, allowing the program to utilize all available CPU-cores efficiently. This is similar to the async event-loop, but over multiple cores.

A Go program using goroutines to send HTTP requests concurrently:

func main() {    urls := []string{"https://www.google.com", "https://duckduckgo.com", "https://search.brave.com"}    waitGroup := sync.WaitGroup{}    start := time.Now()     for _, url := range urls {        waitGroup.Go(func() { // Send request in separate goroutine            response, err := http.Get(url)            if err != nil {                fmt.Printf("http get %s: %v\n", url, err)                return            }            defer response.Body.Close()             fmt.Printf(                "%s: HTTP %d took %d ms\n",                 url, response.StatusCode, time.Since(start).Milliseconds(),            )        })    }    waitGroup.Wait() // Wait for all goroutines to finish    fmt.Println("total time taken:", time.Since(start).Milliseconds(), "ms")}

Results (for the curious):

  • duckduckgo.com: HTTP 200 took 88 ms
  • search.brave.com: HTTP 200 took 118 ms
  • www.google.com: HTTP 200 took 293 ms
  • Total time taken: 293 ms

The waitGroup variable is a counter that increment for every goroutine that is started with the waitGroup.Go() function. Once a goroutine has completed, the counter is decremented. waitGroup.Wait() waits until the counter reaches zero, as this signals that all goroutines have finished running. When all goroutines have finished, we print the total runtime of the program.

The rest of the code should be understandable if you're coming from a Python background. If it's not, you can blame me for explaining poorly.

Conclusion

I ended up migrating my Python work project to Go. This was doable as the code hadn't grown too big at the point where I considered the rewrite. I was able to cut the total tech-stack from 1x Python/Flask container, 1x Redis container and 3x Python/RQ-workers into a single Go container. The automation tasks that the Flask instance would send via Redis to be performed by the workers were instead handled within the Go binary in separate goroutines. This simplified architecture was even faster, all thanks to Go being a faster language.

Overall, Go has taught me a lot about programming that I would never have learned from only using Python. Its benefits over Python has made it my preferred language. Even if Go is not for you, I highly recommend learning a second language just because of how it exposes you to new ways of thinking.

Reading recommendations if you want to learn more about Go:

Copyright 2021-2026, Emil Boklund.
All Rights Reserved.