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.tomlorrequirements.txtinstead ofgo.mod. -
Maps and iteration. Python dicts and
for key, value in ...feel like Go maps andrange, 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!
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
Gotcha: == vs is
a = [1, 2]
b = [1, 2]
print(a == b) # True (same value)
print(a is b) # False (different objects)
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)
}
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()
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]
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())
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
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 / 2gives2.5), and//is floor division. -
Strings: f-strings (
f"{name}: {score}") replace most day-to-dayfmt.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 Noneis not the same asif 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()
Python:
from myapp.auth.middleware import require_token
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
-
import moduleis good for large or clash-prone modules (os,logging). -
from module import nameis 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
- The standard library
- Packages in the active virtual environment
- Your project root, if it's on
sys.path(run from the root,pip install -e ., or usePYTHONPATH)
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
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:
...
from auth.service import validate, User
# or
from auth import service
service.validate("...")
Packages often re-export a clean surface in __init__.py:
# auth/__init__.py
from .service import validate, User
__all__ = ["validate", "User"]
Relative imports (inside a package only)
# auth/middleware.py
from .service import validate
from ..config import settings
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()
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)]
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 buildand 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
-
A virtualenv for every project, like a non-optional
go.mod. - Type-hint public APIs, and let tools enforce them.
-
Use
with, comprehensions, and pytest, instead of translating every Go loop. -
Catch exceptions at the edges rather than copying
if err != nilonto every line. - Treat
is Noneand falsiness as different checks. -
Use an editable install (
pip install -e .) sofrom 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)