Learn Go by Rewriting Small Python Programs You Know
Six small Python programs rewritten in Go with matching output: word counts, dataclasses, exceptions, generators, JSON and threads, plus what differs.

If you write Python, you already know how to solve most of the problems that beginners practice on. What you lack is a map from the idioms you use to the ones Go uses, and the quickest way to build that map is to translate programs whose answers you know. This article takes six small Python programs, each a pattern you have written, runs them and runs the Go version beside them, and compares the output. The Python is real and was run on Python 3.14. The Go is the way I would write it, not a line-by-line transliteration. At each step the difference in the two is the lesson. It is a project-style article in the Go basics path, and the finished programs are all in the article, since each one is short enough to read in full. Do the Go versions with a module open: go mod init example.test/py2go and a main.go per program.
Tested with Go 1.25.6 and Python 3.14, macOS on Apple Silicon (arm64)
What we are building, and what it will not do
The output of this article is a handful of programs that behave the same in two languages, and one small command line tool at the end that you could keep. It will not teach you Go's whole type system, and it does not use any third-party package: everything is the standard library, so a Go 1.25 toolchain is the only prerequisite. Budget about two hours if you type the programs yourself, which is the point. Python 3 is needed only to compare outputs, and you can skip those blocks.
Before you start: the shape of the change
Four differences explain nearly every translation, so hold them in mind.
Types are declared. A Python variable can hold anything and change its mind. A Go variable has a type fixed at compile time, either written out or inferred from its first value, as zero values and type conversion explain. You will write more characters and catch more mistakes before running.
Errors are values. Python raises, and any caller up the stack may catch. Go returns an error and the caller must look at it, or visibly ignore it, as handling errors in Go lays out.
There is no inheritance and no operator overloading. Reuse is composition and interfaces, and a type satisfies an interface by having the methods, which is how Python's duck typing feels, except the compiler checks it.
Everything is compiled into one binary. There is no virtual environment, no pip install into the interpreter and no __init__.py. A module has a go.mod, and go run . compiles and runs.
1. Counting words
The Python version leans on collections.Counter.
Here it is, run, with the output below it:
from collections import Counter
text = "go python go rust go python go rust python c"
counts = Counter(text.split())
for word, n in counts.most_common(3):
print(word, n)
print(len(counts))go 4
python 3
rust 2
4The Go version builds the map itself, then sorts the entries, because a map has no order and no most_common:
package main
import (
"cmp"
"fmt"
"slices"
"strings"
)
type entry struct {
word string
count int
}
func main() {
text := "go python go rust go python go rust python c"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
entries := make([]entry, 0, len(counts))
for w, n := range counts {
entries = append(entries, entry{w, n})
}
slices.SortFunc(entries, func(a, b entry) int {
if c := cmp.Compare(b.count, a.count); c != 0 {
return c
}
return strings.Compare(a.word, b.word)
})
for _, e := range entries[:3] {
fmt.Println(e.word, e.count)
}
fmt.Println(len(counts))
}go 4
python 3
rust 2
4Both print go 4, python 3 and rust 2, then the count of four distinct words. The first two things to notice are small and typical. counts[w]++ works on a missing key because the zero value of an int is zero, which is Python's defaultdict(int) behavior built in, as the maps guide shows. And the tie-break in the sort comparison is explicit. I chose a text with no ties in the top three so the outputs match, but Python's most_common keeps insertion order for ties and a Go map has no order, so ties would otherwise come out in a random order, as the iteration order article demonstrates. That is the kind of difference that bites when a Python program is ported without noticing.
2. Records and filtering
Python reaches for a dataclass and a list comprehension:
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
active: bool
users = [User("ada", 36, True), User("kid", 9, True), User("grace", 45, False), User("linus", 28, True)]
adults = sorted((u for u in users if u.active and u.age >= 18), key=lambda u: u.age, reverse=True)
print([u.name for u in adults])
print(adults[0])['ada', 'linus']
User(name='ada', age=36, active=True)The struct replaces the dataclass, and the comprehension becomes a loop:
package main
import (
"cmp"
"fmt"
"slices"
)
type User struct {
Name string
Age int
Active bool
}
func main() {
users := []User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}}
var adults []User
for _, u := range users {
if u.Active && u.Age >= 18 {
adults = append(adults, u)
}
}
slices.SortFunc(adults, func(a, b User) int { return cmp.Compare(b.Age, a.Age) })
names := make([]string, 0, len(adults))
for _, u := range adults {
names = append(names, u.Name)
}
fmt.Println(names)
fmt.Printf("%+v\n", adults[0])
}[ada linus]
{Name:ada Age:36 Active:true}The loop is longer than the comprehension, and that is the main readability cost people notice first. The Go community accepts it for the sake of an obvious cost model and no hidden allocations, and the standard library has grown helpers (slices.DeleteFunc, iterators) for the repetitive cases. Python's print(adults[0]) produces User(name='ada', age=36, active=True) from the dataclass for free, and in Go the %+v verb gives the nearest thing, {Name:ada Age:36 Active:true}, as printing structs covers. The struct's field names are capitalized because that is what exports them, which matters the moment another package, or encoding/json, needs to see them.
3. Errors instead of exceptions
A Python function raises, and the caller wraps the call in try:
def parse_port(text):
try:
port = int(text)
except ValueError:
raise ValueError(f"port must be a number, got {text!r}") from None
if not 1 <= port <= 65535:
raise ValueError(f"port {port} out of range")
return port
for value in ["8080", "http", "70000"]:
try:
print(parse_port(value))
except ValueError as e:
print("error:", e)8080
error: port must be a number, got 'http'
error: port 70000 out of rangeIn Go the function returns the error, and the caller handles it on the next line:
package main
import (
"errors"
"fmt"
"strconv"
)
var ErrRange = errors.New("out of range")
func parsePort(text string) (int, error) {
port, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("port must be a number, got %q", text)
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("port %d: %w", port, ErrRange)
}
return port, nil
}
func main() {
for _, value := range []string{"8080", "http", "70000"} {
port, err := parsePort(value)
if err != nil {
fmt.Println("error:", err, errors.Is(err, ErrRange))
continue
}
fmt.Println(port)
}
}8080
error: port must be a number, got "http" false
error: port 70000: out of range trueThe messages match, apart from Python's single quotes around http and the extra boolean I added to show errors.Is. Python has exception classes to tell failures apart, and Go uses sentinel values and types, tested with errors.Is and errors.As, as the errors article explains. The first thing that feels wrong to a Python developer is the if err != nil after every call. The second is that nothing stops you ignoring it: a function that returns an error and is called without looking at the result compiles. Linters such as errcheck catch that, and the habit of reading the next line for an err is the whole discipline.
4. Generators become iterators
Python's generator is a function with yield:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
for n in fibonacci():
if n > 50:
break
print(n, end=" ")
print()0 1 1 2 3 5 8 13 21 34Since Go 1.23 the equivalent is a function that takes a yield callback, and the for loop over it looks the same:
package main
import (
"fmt"
"iter"
)
func fibonacci() iter.Seq[int] {
return func(yield func(int) bool) {
a, b := 0, 1
for {
if !yield(a) {
return
}
a, b = b, a+b
}
}
}
func main() {
for n := range fibonacci() {
if n > 50 {
break
}
fmt.Print(n, " ")
}
fmt.Println()
}0 1 1 2 3 5 8 13 21 34Both print the Fibonacci numbers up to 34. The mechanics differ: Python suspends the function at each yield, and Go calls your loop body as a function and stops the iterator when it returns false, as range over functions explains. The practical difference is that forgetting the if !yield(a) { return } check in Go causes a panic when the loop breaks, whereas Python handles it for you. For ordinary collections the question never arises, since you range over a slice or map directly.
5. JSON in and out
Python builds a dictionary and dumps it:
import json
doc = {"name": "ada", "tags": ["a", "b"], "meta": {"admin": True}}
text = json.dumps(doc, sort_keys=True)
print(text)
back = json.loads(text)
print(back["meta"]["admin"], len(back["tags"])){"meta": {"admin": true}, "name": "ada", "tags": ["a", "b"]}
True 2Go prefers a struct with tags, which both documents the shape and checks it:
package main
import (
"encoding/json"
"fmt"
)
type Meta struct {
Admin bool `json:"admin"`
}
type Doc struct {
Meta Meta `json:"meta"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
func main() {
doc := Doc{Name: "ada", Tags: []string{"a", "b"}, Meta: Meta{Admin: true}}
text, err := json.Marshal(doc)
fmt.Println(string(text), err)
var back Doc
err = json.Unmarshal(text, &back)
fmt.Println(back.Meta.Admin, len(back.Tags), err)
}{"meta":{"admin":true},"name":"ada","tags":["a","b"]} <nil>
true 2 <nil>I ordered the struct fields alphabetically so the keys come out in the same order as sort_keys=True, since Go writes struct fields in declaration order. The spacing differs: Python's dumps puts a space after each separator by default and Go's Marshal writes compact JSON. The decoder wants a pointer, &back, because it must fill your variable, and it returns an error that the Python version would have raised. When you do not know the shape, map[string]any is the equivalent of Python's plain dict, at the cost of type assertions, as the type switch article shows. Struct tags explain the backtick strings.
6. Threads and goroutines
A thread pool in Python maps a function over inputs:
from concurrent.futures import ThreadPoolExecutor
def square(n):
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
print(list(pool.map(square, range(8))))[0, 1, 4, 9, 16, 25, 36, 49]In Go, start a goroutine per item and collect results by index so the order is preserved:
package main
import (
"fmt"
"sync"
)
func square(n int) int { return n * n }
func main() {
inputs := []int{0, 1, 2, 3, 4, 5, 6, 7}
results := make([]int, len(inputs))
var wg sync.WaitGroup
for i, n := range inputs {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = square(n)
}()
}
wg.Wait()
fmt.Println(results)
}[0 1 4 9 16 25 36 49]Both print the squares in input order. The Go version has no pool and no futures: goroutines are cheap enough to start one per item, each writes a different slot of the slice so no lock is needed, and WaitGroup waits for them all, as WaitGroups explains. The deeper difference is that these goroutines run in parallel on separate cores, and Python's threads share the interpreter lock, so for CPU-bound work the Go version scales and the Python one does not. For real workloads you bound the goroutines, as errgroup with a limit shows, and the shape of this program is the beginning of Go concurrency patterns.
7. Put it together: a word-count tool
The first program counted a fixed string. A tool you could use reads text from a pipe and takes a flag for how many words to show. In Python that is sys.stdin and argparse. In Go it is bufio.Scanner and the flag package, and I write the counting as a function that takes an io.Reader, as reading user input recommends, so that it is testable without a terminal:
package main
import (
"bufio"
"cmp"
"io"
"slices"
"strings"
)
type Entry struct {
Word string
Count int
}
// TopWords returns the n most frequent words in r, lowercased, with punctuation trimmed.
func TopWords(r io.Reader, n int) ([]Entry, error) {
counts := map[string]int{}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
for _, w := range strings.Fields(strings.ToLower(scanner.Text())) {
if w = strings.Trim(w, ".,;:!?\"'()"); w != "" {
counts[w]++
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
entries := make([]Entry, 0, len(counts))
for w, c := range counts {
entries = append(entries, Entry{w, c})
}
slices.SortFunc(entries, func(a, b Entry) int {
return cmp.Or(cmp.Compare(b.Count, a.Count), strings.Compare(a.Word, b.Word))
})
if n < len(entries) {
entries = entries[:n]
}
return entries, nil
}package main
import (
"flag"
"fmt"
"os"
)
func main() {
n := flag.Int("n", 3, "number of words to show")
flag.Parse()
entries, err := TopWords(os.Stdin, *n)
if err != nil {
fmt.Fprintln(os.Stderr, "wordstat:", err)
os.Exit(1)
}
for _, e := range entries {
fmt.Printf("%-8s %d\n", e.Word, e.Count)
}
}printf 'Go is fun. Go is fast.\nIs Go simple?\n' | go run . -n 2
printf 'one two\n' | go run . -n many 2>&1 | head -1; truego 3
is 3
invalid value "many" for flag -n: parse errorThe first run prints go 3 and is 3, tied and ordered alphabetically by the second key in the comparison, and the second shows flag rejecting a value that is not a number with its own message and an exit status. This is argparse for free, with the usage text generated from the declarations. The TopWords function is the part worth testing, and it is five lines:
package main
import (
"strings"
"testing"
)
func TestTopWords(t *testing.T) {
got, err := TopWords(strings.NewReader("Go is fun. Go is fast.\nIs Go simple?"), 2)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != (Entry{"go", 3}) || got[1] != (Entry{"is", 3}) {
t.Fatalf("got %v", got)
}
}=== RUN TestTopWords
--- PASS: TestTopWords (0.00s)
PASS
ok example.test/lab 1.251sWriting the test cost more lines than the program's logic, which is typical of Go, and it needed no framework, since go test finds files ending in _test.go. The Python equivalent would use pytest or unittest, and the habit of a table of cases carries over, as table tests in the functions article shows.
A translation table
After a few of these, the idioms form a lookup table. Keep this beside you for the next program:
| Python | Go |
|---|---|
list, list.append(x) | []T, s = append(s, x) |
dict, d.get(k, 0) | map[K]V, m[k] (zero value for a missing key) |
set | map[T]struct{} |
for x in xs | for _, x := range xs |
for i, x in enumerate(xs) | for i, x := range xs |
[f(x) for x in xs if p(x)] | A loop with if and append |
sorted(xs, key=...) | slices.SortFunc with cmp.Compare |
None | nil, or a pointer, or the zero value |
try/except | if err != nil |
with open(f) as fh | f, err := os.Open(f) then defer f.Close() |
class with methods | struct with methods |
| Duck typing | An interface, defined by the consumer |
import json | import "encoding/json" |
f"{x} is {y}" | fmt.Sprintf("%v is %v", x, y) |
| Threads | Goroutines |
pip install, venv | go get, a go.mod |
Taking it further
Three directions, each a direct extension of what you built. First, make wordstat read file names from its arguments and fall back to standard input, following command line arguments and reading files. Second, count the words of several files concurrently and merge the maps, which is a worker pool problem and the opening of fan-out and fan-in. Third, serve the counts over HTTP as JSON, which brings in the web material that comes after this path. Each is an hour's work now that the pieces are familiar.
How much faster is it, really
The usual reason to move from Python is speed, so measure one case. This is the same CPU-bound function in both languages, a naive recursive Fibonacci of 32, timed inside each program:
import time
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
start = time.perf_counter()
result = fib(32)
print(result, f"{(time.perf_counter() - start) * 1000:.0f} ms")2178309 240 mspackage main
import (
"fmt"
"time"
)
func fib(n int) int {
if n < 2 {
return n
}
return fib(n-1) + fib(n-2)
}
func main() {
start := time.Now()
result := fib(32)
fmt.Println(result, time.Since(start).Milliseconds(), "ms")
}2178309 13 msPython took about a quarter of a second and Go took about a twentieth of that, a factor of roughly eighteen on one machine, with the same answer, 2178309. That is the kind of gap you get on plain interpreted loops and calls. It is an upper bound for programs that spend their time in your own code. A web service that waits on a database spends most of its time waiting, and the gap shrinks toward the difference in per-request overhead. Python code that calls into NumPy, pandas or a compiled extension runs those parts at native speed, and Go has no equivalent ecosystem for numerical work. So the honest summary is that Go is much faster at the kind of work you write yourself and about the same where Python is already calling C.
Errors you will hit while translating
Rewriting from Python produces a predictable set of compile errors, and each has an article. declared and not used appears the first time you leave a variable behind, and its article shows every form. missing return appears when a function's last statement is an if, as in the missing return article. invalid operation: mismatched types int and float64 appears the first time you divide a count by a float, and that error explains why no implicit conversion exists. cannot use x as type is the general case, and the assignment error article covers it. And a nil map write panic, assignment to entry in nil map, is what happens when a Python habit of "just assign to a dict" meets var m map[string]int.
Treat each of these as the compiler teaching you the type system. Python finds the same mistakes at run time, in production, if the path is exercised, and Go finds them before the program starts. The price is the minutes at your desk, and a first week of Go feels slow for that reason.
How I would continue
Pick a script you actually use, a log summarizer, a file renamer, a small API client, and port it, using the table above. Use the standard library first and notice how much of what you reached for a package for is there already: encoding/csv, net/http, text/template, flag, os/exec. Read the idiomatic Go article after the port, when you have code to compare it against. And run gofmt, go vet and go test as you go, as your first program set up.
Where this exercise falls short
Translating small programs teaches syntax and idiom, and it hides the part of Go that people from Python find hardest: the shape of a larger program. A script is one file, and a Go service is packages with a deliberate dependency direction, interfaces defined where they are consumed, constructors that return errors and tests alongside every package. None of the six programs above needs any of that. When you feel comfortable with the translations, build one thing that is big enough to need packages and tests, a small API with a store behind an interface is a good size, and expect the second week to be harder than the first. That is normal, and it means the design habits, not the syntax, are what you are now learning. The reading list for that step is packages, interfaces and structs.
The decision I would revisit
The translations above are faithful. A few of them I would not write that way in a real Go program. The word counter would sort by a single key and drop the tie-break if the ties did not matter, which loses a source of confusion. The error example would define a custom error type with a field for the offending value if callers needed it. And I would not use goroutines for squares: at eight items the overhead exceeds the work. The point of a rewrite exercise is the idiom, not the efficiency.
Where Python is still the better tool
Go does not replace Python for exploration, data analysis, notebooks, scripting that lives for a day or anything that leans on its scientific libraries. The REPL, dynamic typing and pip ecosystem are real advantages for those tasks. Go's strengths are programs that run for a long time, serve many clients, ship as one binary or need to use all the cores. Keep both. If you want to continue, the Go basics path is the order I would read in, and the tour of Go is the best interactive companion. The standard library documentation at pkg.go.dev/std is where the equivalents of Python's batteries live.


