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 and Python was my language of choice. As it was reaching 10,000 lines of code I started to hit roadblocks due to design mistakes I had made. I had also found some warts in how I used the language. Looking for inspiration, I found Go. This article 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, it is wrapped in a try/except block to 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.

I had implemented a similar setup in my Python project. My own functions were standardized to always return 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 = read_file("input.txt")    if not result.success:        print("read file:", result.message)        exit(1)    print(f"{len(result.data)} in file") 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 the success boolean was set to false. Each function could add some context before passing the result further 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 lay 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.Println("read file:", 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 alwaysreturned in a separate variable 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 can avoid using 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 in your project.

JSON Parsing

Go has builtin support for parsing JSON. Python can do this too, but Go will allow you to parse the data into a struct (similar to Python class) even though the JSON key does not fully match the name of the struct field.

Let's see an example:

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))    // JSON string output (prettified):    // [    //   {"name": "R1", "serial-number": "aabbcc"},    //   {"name": "R2", "serial-number": "bbccdd"}    // ] 	// Unmarshal JSON data back into slice of Router	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 (Class in Python). The := symbol 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 example shows 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 was a big deal for as a lot what my code did was interact with external JSON-based API's. I honestly didn't know about Pydantic at the time, which could have solved a few of those problems for me.

These struct tags support languages like yaml, toml or even SQL. Some tags require third-party packages though.

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. These annotations are ignored by the interpreter though, so if you ignore your IDE you can still blow up your program at runtime.

Python:

def read_file(file_name):    ... # Validdef read_file(file_name: string) -> Result:    ... # Valid

These are both valid Python because the interpreter ignores the annotations. Annotations are highly recommended for code stability and to keep your sanity.

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 it becomes a helper as your code grows. If you do something wrong, the program will not compile.

Go:

func readFile(fileName) {    ... // Invalid, will not compile}func readFile(fileName string) (content string, err error) {    ... // Valid}

I commonly rename fields in my struct but I don't update the code referencing that field, so it's still using the old name. With Python the issue would be caught at runtime when that particular code would try to execute and instead blow up. Here the Go compiler has my back, helpfully screaming at me about field undefined before the program even has a chance to start.

I've also had a few cases where I changed the number of arguments that was passed into a function but did not update all code calling that function. The Go compiler will catch that too. Python can't really catch these mistakes, or atleast not the tools I used.

Performance

Python is an interpreted language. The python3 binary is the interpreter and must be installed on your machine to execute the Python file. The fact that the code has to be interpreted at runtime means that extra work has to be performed, making the code slower 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.

Casey Muratori has a video series on optimizing code. In one of his videos (paywalled) he show that an ADD operation in Python generate about 180 assembly instructions, which is what the CPU reads to perform the task. An equivalent ADD in C (he did not look at Go) generated one assembly instruction. About 70 instructions were various function calls to get to the ADD instruction and another 100 instructions to clean up after. Python was not made to be fast, but it's good to be aware of how slow and inefficient the language can be.

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. While it is not exactly assembly, it's a closer representation than the bytecode that the Python interpreter parses. A simplified explanation of the difference is that the Go compiler does some work at compile time that the Python interpreter instead has to do at runtime. 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 GC. 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 better thanks to how it traverses memory more efficiently during the mark phase. The less 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:

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.

Async is one reason why FastAPI is a faster web framework than the synchronous Flask. If your HTTP endpoint receives a request and makes a call to a database before returning a response, FastAPI can switch between multiple requests while Flask is stuck waiting for the DB response for one 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 blocks just like Flask would. This makes it important to figure out which Python 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 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 on these OS-threads, allowing the program to fully utilize all available CPU-cores. This is similar to the async event-loop, but over multiple cores.

The Go HTTP server handle each incoming request in its own separate goroutine. This is possible thanks to how lightweight and cheap goroutines are to instantiate.

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 193 ms
  • Total time taken: 193 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.

Note that I have cheated a bit here. Each goroutine prints its result directly to stdout. If I were to collect the results in a central data structure, I would have to use a Mutex or a Channel to avoid data races between the goroutines.

Conclusion

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