DEV Community

Cover image for Learning Python After a Few Years of Go: What Surprised Me So Far
Onah Sunday.
Onah Sunday.

Posted on

Learning Python After a Few Years of Go: What Surprised Me So Far

I've been writing Go for a few years: explicit types, go mod, goroutines, if err != nil, and the comfort of the compiler saying "no" before deploy. Recently I started learning Python.

I'm new to Python, but not new to programming. So this isn't a generic language comparison, and it isn't written by a Python expert. It's what I've actually discovered so far as a Go-heavy brain meeting a dynamic, batteries-included ecosystem.

If you're an experienced Pythonista and I got something wrong, please tell me in the comments. If you're coming from Go too, I hope this saves you a few surprises.


What stayed familiar

Some things translated almost one-to-one.

  • Modules and imports. Files and packages still organize code. You still manage dependencies, just with a virtual environment and pyproject.toml or requirements.txt instead of go.mod.
  • Maps and iteration. Python dicts and for key, value in ... feel like Go maps and range, with less typing and different missing-key behavior (.get() vs (v, ok)).
  • Functions as values. Closures and callbacks exist in both. In Python they show up everywhere (decorators, key= arguments, lambdas) more than in typical Go style.
  • Testing culture. Table-driven tests have a cousin in pytest parametrization. The habit of "many cases, one function" carries over well.

What surprised me

1. Variables aren't typed slots, they're names on objects

In Go, var x int and x := 10 attach a type to a variable. In Python, x = 10 binds a name to an object. Reassigning x = "hello" is legal: the name moved, and the old int may still exist elsewhere.

That one idea explains a lot of "why did it do that?" moments. Assignment doesn't copy a struct-like value by default. It copies a reference, so mutating a list or dict through one name affects every name pointing at that object.

Discovery: I stopped looking for type declarations on every line and started asking: what object does this name point to, and is it mutable?

2. There is no compile-time safety net unless you build one

Go trained me to trust the compiler. Python runs first and complains later.

Type hints (def f(x: int) -> str:) don't change runtime behavior, but running mypy or Pyright in CI gives you a slice of Go-like checking.

Discovery: Professional Python often means type hints + a linter + tests, not "dynamic means anything goes."

3. "No pointers" doesn't mean "no aliasing"

For immutable things (int, str, tuple), behavior often looks like pass-by-value. For lists, dicts, and custom objects, it's shared mutation.

When I need a copy, I have to ask for one (list.copy(), copy.deepcopy()). Go made this obvious with * and &. Python hides it behind semantics.

This leads straight into the first gotcha I ran into.

Gotcha: mutable default arguments

def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  <- surprise!
Enter fullscreen mode Exit fullscreen mode

The default list is created once, when the function is defined, and shared across calls. The usual fix:

def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
Enter fullscreen mode Exit fullscreen mode

Gotcha: == vs is

a = [1, 2]
b = [1, 2]

print(a == b)  # True  (same value)
print(a is b)  # False (different objects)
Enter fullscreen mode Exit fullscreen mode

Use == to compare values and is to check identity. The main place you want is is x is None.

4. Error handling is a culture shift

My Go reflex:

if err != nil {
    return fmt.Errorf("loading config: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

Python's default is exceptions that bubble up until something catches them. Idiomatic code often raises deep inside and catches at boundaries (a CLI command, an HTTP handler, a job runner) rather than around every line.

Discovery: Check explicitly for expected failures like validation and parsing, and use exceptions for exceptional paths or deep call stacks. Both are professional. The mix is the lesson.

5. Cleanup is with, not defer

with open("data.txt") as f:
    data = f.read()
Enter fullscreen mode Exit fullscreen mode

Discovery: Context managers are to Python what defer is to Go: the idiomatic way to say "always release this."

6. Concurrency is a different animal

  • CPU-bound work: the GIL (Global Interpreter Lock) limits threads for compute-heavy code, so processes or native extensions matter.
  • I/O-bound work: asyncio (async / await) is the closest mental model to lightweight goroutines, but it's cooperative and depends on async-aware libraries.

Here's the same idea, three tasks running concurrently. First Go:

var wg sync.WaitGroup
results := make([]int, 3)

for i := range results {
    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(time.Second)
        results[i] = (i + 1) * 2
    }()
}
wg.Wait()
fmt.Println(results) // [2 4 6]
Enter fullscreen mode Exit fullscreen mode

And Python:

import asyncio

async def work(n):
    await asyncio.sleep(1)
    return n * 2

async def main():
    results = await asyncio.gather(work(1), work(2), work(3))
    print(results)  # [2, 4, 6]

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

The Python version is shorter, but I have to remember that everything in the chain must be async, and one blocking call can stall the whole event loop.

A note on the GIL: recent Python versions include an optional free-threaded build that removes it. Check the current status for the version you use before relying on it.

Discovery: I now map problems to asyncio vs threading vs multiprocessing instead of reaching for goroutines by default.

7. The standard library is huge

JSON, HTTP, pathlib, dataclasses, logging: a lot is already included. I still reach for pytest, httpx or requests, and pydantic where Go might use a few well-known dependencies.

8. dataclass is my struct comfort zone

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
Enter fullscreen mode Exit fullscreen mode

For API payloads and DTOs, dataclass or Pydantic scratch the same itch as Go structs with JSON tags.


Types and values: a map for Go eyes

Go Python (what I use in practice)
int, float64 int (arbitrary precision), float
string, []byte str, bytes
[]T slice list (mixed types allowed)
map[K]V dict
struct @dataclass or class
interface duck typing, or typing.Protocol for contracts
nil None (use is None, not == None)
zero value no universal zero; know the falsy values (None, 0, "", [], {})
(v, ok) .get(key), or try / except KeyError
  • Numbers: / is true division (5 / 2 gives 2.5), and // is floor division.
  • Strings: f-strings (f"{name}: {score}") replace most day-to-day fmt.Sprintf.
  • Tuples and sets: Go has no first-class versions of these. Python has immutable tuples (1, 2) and sets {1, 2, 3}.
  • Booleans: Python has many falsy values, which is broader than Go's zero-value checks. For optional values, if x is None is not the same as if not x.

Importing code: what Go made obvious, and what Python does differently

In Go, imports are rigid: one module path, one package per directory, exported names capitalized. Python looks simpler (import foo), but the rules live in file layout, packages, and convention.

Mental model: module, package, file

Go Python
Module = repo + go.mod Project = folder with pyproject.toml / requirements.txt
Package = directory with a shared package name Package = directory (often with __init__.py)
File = part of one package Module = one .py file (utils.py is module utils)

Go:

import "github.com/acme/myapp/internal/auth"

auth.Middleware()
Enter fullscreen mode Exit fullscreen mode

Python:

from myapp.auth.middleware import require_token
Enter fullscreen mode Exit fullscreen mode

The layout has to match what Python can find on sys.path, not just what you wish the folder tree looked like.

Import styles

import json
json.loads('{"a": 1}')

from json import loads
loads('{"a": 1}')

import json as js
from pathlib import Path
Enter fullscreen mode Exit fullscreen mode
  • import module is good for large or clash-prone modules (os, logging).
  • from module import name is idiomatic for small, clear APIs (from dataclasses import dataclass).

Go's closest habit is import aliases (import f "fmt"). Python uses from and aliases constantly, and that's normal.

Where Python looks for modules

  1. The standard library
  2. Packages in the active virtual environment
  3. Your project root, if it's on sys.path (run from the root, pip install -e ., or use PYTHONPATH)

Discovery: Running from the wrong directory, or forgetting the venv, feels like pre-modules Go. Imports fail at runtime. One venv per project is non-optional, just like go.mod per service.

Project layout (mirroring Go instincts)

src/myapp/
    __init__.py
    main.py
    auth/
        __init__.py
        middleware.py
        service.py
pyproject.toml
Enter fullscreen mode Exit fullscreen mode

Go's internal/ is enforced by the compiler. Python's "private" is a _leading_underscore: convention, not a rule.

Go Python
Foo / foo _helper means "internal, please don't"

Callers can still import _helper. The discipline is social and enforced in code review.

Importing functions, classes, and constants

# auth/service.py
def validate(token: str) -> bool:
    ...

class User:
    ...
Enter fullscreen mode Exit fullscreen mode
from auth.service import validate, User
# or
from auth import service
service.validate("...")
Enter fullscreen mode Exit fullscreen mode

Packages often re-export a clean surface in __init__.py:

# auth/__init__.py
from .service import validate, User

__all__ = ["validate", "User"]
Enter fullscreen mode Exit fullscreen mode

Relative imports (inside a package only)

# auth/middleware.py
from .service import validate
from ..config import settings
Enter fullscreen mode Exit fullscreen mode

Go uses full paths only, with no ... Relative imports break if you run a nested file directly (python auth/middleware.py). Prefer python -m myapp or an installed package, similar to running from the module root in Go.

init vs import-time code

Go has func init() at package load. In Python, importing a module runs it once, and __init__.py runs when the package is first imported. I keep __init__.py thin: re-exports and docs, not heavy side effects.

Circular imports

This is the same class of problem as Go import cycles. Fixes: extract a small shared module, import lazily inside a function, or restructure the packages.

Third-party dependencies

Use pip install plus a lockfile (Poetry, uv, and others). Watch for PyPI name vs import name: pip install Pillow gives you import PIL.

Library vs entrypoint

def main():
    ...

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Importing the file loads the definitions but does not run main(). It only runs when the file is executed as a script, which is like separating package auth from cmd/server.

Import cheat sheet

I want to... Go Python
Import a package import "path/pkg" import pkg
Use one function pkg.F() from pkg import F
Rename import p "path/pkg" import pkg as p
Hide internals lowercase name _name
Entrypoint func main() in cmd/... if __name__ == "__main__": or python -m pkg
Local monorepo lib replace in go.mod pip install -e ./libs/foo

Functions, control flow, and iteration

Go has multiple returns, defer, and C-style for. Python has a single return value (use tuples for several), with instead of defer, and for x in iterable as the default loop.

Comprehensions are idiomatic where Go would use a loop plus append:

squares = [n * n for n in range(10)]
Enter fullscreen mode Exit fullscreen mode

Variadic functions: Go uses ...T, Python uses *args and **kwargs.


What Go still does better

Learning Python hasn't made me love Go any less. A few things I miss:

  • Compile-time errors. Typos in rarely-run code paths only show up when they run.
  • Single-binary deployment. go build and ship one file. Python needs an environment, dependencies, or a container.
  • Built-in concurrency. Goroutines and channels need no async/await coloring of your functions.
  • Predictable performance for CPU-heavy work.

Neither language is "better." They optimize for different things.


What I'm still figuring out

Being honest, here's what hasn't clicked yet:

  • Async code in larger projects, and how to mix it with blocking libraries
  • Packaging and dependency tools (pip, Poetry, uv): which one, and when
  • When to use a plain class, a dataclass, or a Pydantic model
  • Reading and writing good type hints for more complex code

If you have advice on any of these, I'd really like to hear it.


Habits I deliberately changed

  1. A virtualenv for every project, like a non-optional go.mod.
  2. Type-hint public APIs, and let tools enforce them.
  3. Use with, comprehensions, and pytest, instead of translating every Go loop.
  4. Catch exceptions at the edges rather than copying if err != nil onto every line.
  5. Treat is None and falsiness as different checks.
  6. Use an editable install (pip install -e .) so from myapp... imports behave like a real module graph.

Closing

Learning Python as a Go developer isn't a downgrade or a "scripting detour." It's a language optimized for expression speed and ecosystem breadth, where discipline shows up in tests, types, project layout, and imports as much as in the compiler.

The uncomfortable parts (dynamic typing, exceptions, the GIL, import paths) aren't gaps in my education. They're different tradeoffs. Once I named them (references vs values, boundaries vs inline errors, asyncio vs goroutines, modules vs go.mod), Python stopped feeling like "Go with missing braces" and started feeling like a tool I'm getting comfortable with.

If you're coming from Go too, you're not starting from zero. You're retargeting a structured mindset.

Your turn: What surprised you most when you switched languages? Tell me in the comments.

Top comments (0)