Skip to main content

Python for Go Developers

Python is the lingua franca of AI engineering. From PyTorch and TensorFlow to LangChain and LlamaIndex, every major framework in the AI ecosystem is built in Python. If you come from a Go background, you already have strong engineering instincts β€” compile-time discipline, explicit error handling, and a bias toward simplicity. You just need to learn the new vocabulary and tooling.

This guide bridges that gap. By the end, you will understand how your Go knowledge maps to the Python world and be ready to contribute to our AI engineering projects.

Prerequisites

This guide assumes you are comfortable with Go and want to learn Python specifically for AI engineering. If you are new to Python entirely, also read the Python Getting Started guide which covers the tooling stack in more depth.


Why Python for AI Engineering​

Go is an excellent language. Its compile speed, concurrency model, and simplicity are real strengths. But the AI/ML ecosystem is overwhelmingly Python-native. Here is why:

  • Framework dominance. PyTorch, TensorFlow, JAX, LangChain, LlamaIndex, Hugging Face Transformers β€” all first-class Python APIs.
  • Data science stack. NumPy, Pandas, Polars, scikit-learn form the foundation of data manipulation and analysis.
  • Rapid prototyping. Python's dynamic typing and REPL-friendly nature make it ideal for experimentation and iteration.
  • Community and documentation. When you encounter an error in an AI library, the top search results will almost always be Python-centric.
  • Model interoperability. The Hugging Face model hub, ONNX, and most model serialization formats are Python-first.

This does not mean Python replaces Go in your toolkit. Many production systems use a hybrid approach: Go for high-throughput services and APIs, Python for AI/ML workloads. But you need to be fluent in both.


The Mental Model: Go β†’ Python Mapping​

The fastest way to learn Python is to understand what corresponds to the tools and concepts you already know. The following table maps your Go ecosystem to the Python equivalents we use on this team.

Go ConceptPython EquivalentNotes
go moduvuv is our package manager β€” orders of magnitude faster than pip
go.modpyproject.tomlSingle file for metadata, dependencies, and tool configuration
go.sumuv.lockDeterministic lockfile for reproducible builds
go runuv runExecute code with dependencies resolved
go getuv addAdd a dependency to your project
go installuv tool install / uvxInstall or run tools without project dependency
golangci-lintruffOne tool replaces linting and formatting, 10–100Γ— faster
gofmtruff formatOpinionated code formatting
go testpytestThe de facto Python test runner
go vettyStatic type checker (10–100Γ— faster than mypy)
interface{}typing.AnyDynamic / untyped values
struct@dataclass / Pydantic modelStructured data with auto-generated methods
goroutinesasyncio / async functionsCooperative concurrency model
context.ContextDependency injection / config passingRequest-scoped state management
if err != niltry / exceptException-based error handling
channelsasyncio.QueueThread-safe message passing
sync.WaitGroupasyncio.gather()Wait for multiple coroutines
$GOPATH / module cache.venv/Isolated virtual environment per project
go builduv build (or just uv sync)Build and install artifacts
Key Insight

The biggest conceptual shift from Go is not syntax β€” it is error handling and typing. Go forces you to check every error at the call site. Python uses exceptions, which bubble up the stack. Go has compile-time type safety. Python has optional type hints checked by a separate tool (ty). Both approaches work; you just need to adapt your mental model.


Setting Up Your Environment with uv​

What is uv?​

uv is an extremely fast Python package and project manager written in Rust by Astral. It replaces pip, virtualenv, pip-tools, and more β€” all while being orders of magnitude faster. It is the single most important tool in the Python workflow for our team.

Think of uv as the go mod + go run + go install of the Python world, but faster.

Installing uv​

curl -LsSf https://astral.sh/uv/install.sh | sh

Verify the installation:

$ uv --version
uv 0.x.x

Your First Project: go mod vs uv​

Here is the complete project setup workflow side by side:

# 1. Initialize module
go mod init github.com/org/my-project

# 2. Add dependencies
go get github.com/gin-gonic/gin
go get github.com/stretchr/testify

# 3. Download and vendor
go mod tidy
go mod vendor

# 4. Run the project
go run cmd/server/main.go

# 5. Install a tool globally
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

Understanding the Project Files​

After running uv init, your project looks like this:

my_project/
β”œβ”€β”€ .git/
β”œβ”€β”€ .gitignore
β”œβ”€β”€ .python-version # Pinned Python version (e.g., "3.12")
β”œβ”€β”€ .venv/ # Virtual environment (created on first uv sync/run)
β”‚ β”œβ”€β”€ bin/
β”‚ β”œβ”€β”€ lib/
β”‚ └── pyvenv.cfg
β”œβ”€β”€ src/
β”‚ └── my_project/ # Your package
β”‚ β”œβ”€β”€ __init__.py
β”‚ └── main.py
β”œβ”€β”€ pyproject.toml # Project metadata + dependencies
└── uv.lock # Lockfile (like go.sum)

The pyproject.toml is the heart of your project configuration:

[project]
name = "my_project"
version = "0.1.0"
description = "My AI engineering project"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"pydantic",
]

[tool.uv.dev-dependencies]
ruff = ">=0.4.0"
ty = ">=0.0.1"
pytest = ">=8.0.0"
go mod β†’ uv Command Cheat Sheet
Go commanduv equivalent
go mod inituv init
go get <pkg>uv add <pkg>
go mod tidyuv lock (or uv sync)
go run ./cmd/...uv run <script>
go install <pkg>@latestuv tool install <pkg>
go env GOPATHuv venv (creates .venv/)
go list -m alluv tree
go mod graphuv lock --show-resolved

Python Syntax Crash Course​

This section highlights the key differences between Go and Python. It is not a complete Python tutorial β€” for that, see the official Python tutorial. Instead, it focuses on what will trip you up coming from Go.

Variables and Type Hints​

Go is statically typed. Python is dynamically typed, but you can (and should) add type hints. Think of type hints as compile-time annotations that a separate tool (ty) checks.

package main

var name string = "Alice"
var age int = 30
var isActive bool = true
var tags []string = []string{"ai", "python"}
var config map[string]any = map[string]any{"key": "value"}

// Shorthand declaration (local scope only)
// name := "Alice"
// age := 30

Key differences:

  • No var / :=. Python has no variable declaration keyword. Assignment is declaration.
  • null β†’ None. Python has a single null value: None.
  • true/false β†’ True/False. Note the capitalization.
  • interface{} β†’ object or typing.Any. For untyped values.
  • Type hints are not enforced at runtime. They exist for static analysis only (ty checks them).
  • No implicit type inference at package level. In Go, var x = 42 infers int. In Python, x = 42 is dynamically typed; add : int for static checking.

Functions and Return Types​

package main

import "fmt"

// Named return values with error handling
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}

// Multiple return values
func minMax(nums []int) (int, int) {
min, max := nums[0], nums[0]
for _, n := range nums {
if n < min {
min = n
}
if n > max {
max = n
}
}
return min, max
}

Key differences:

  • func β†’ def. Python uses the def keyword.
  • No named return values. Python functions return a single value or a tuple.
  • Error handling is via exceptions, not return values. Instead of (result, error), you raise an exception and the caller try/excepts it.
  • Return types. The arrow -> specifies the return type, placed after the parameter list.
  • Indentation matters. Python uses whitespace (4 spaces) for block structure, not curly braces.

Structs vs Dataclasses​

Go's struct is the workhorse of data representation. Python's closest equivalent is the @dataclass decorator.

package main

import "fmt"

type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
}

func NewUser(id int, name, email, role string) *User {
return &User{
ID: id,
Name: name,
Email: email,
Role: role,
}
}

func (u *User) DisplayName() string {
return fmt.Sprintf("%s (%s)", u.Name, u.Role)
}

Key differences:

  • type X struct β†’ @dataclass class X. Dataclasses auto-generate __init__, __repr__, and __eq__ methods from type annotations.
  • self is explicit. Every instance method takes self as the first parameter.
  • No pointer semantics. Python variables are references. There is no *User vs User distinction.
  • Literal for constrained strings. Instead of checking at runtime, use Literal["admin", "user"] for compile-time safety.
  • Struct tags β†’ Pydantic. For JSON serialization with field aliases, use Pydantic models instead of struct tags.

Error Handling​

This is the biggest mental shift. Go's if err != nil pattern is explicit and local. Python's exception model is implicit and stack-wide.

package main

import (
"database/sql"
"fmt"
"log"
)

func getUser(db *sql.DB, id int) (*User, error) {
row := db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)

var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user %d: not found", id)
}
return nil, fmt.Errorf("user %d: %w", id, err)
}
return &u, nil
}

func main() {
db, err := sql.Open("postgres", "dsn")
if err != nil {
log.Fatal(err)
}
defer db.Close()

user, err := getUser(db, 42)
if err != nil {
log.Printf("failed to get user: %v", err)
return
}
fmt.Println(user)
}

Key differences:

  • if err != nil β†’ try / except. Errors are exceptions that propagate up the call stack.
  • defer β†’ with (context managers). Python's with statement handles cleanup automatically, similar to defer.
  • Custom error types β†’ Custom exception classes. Define subclasses of Exception for domain-specific errors.
  • fmt.Errorf("...: %w", err) β†’ raise ... from err. Python supports exception chaining with from.
  • No error wrapping syntax. Use exception chaining (raise NewError from original) instead of %w.
EAFP vs LBYL

Python follows the "Easier to Ask Forgiveness than Permission" (EAFP) philosophy. Go follows "Look Before You Leap" (LBYL). In Go, you check for errors before proceeding. In Python, you try the operation and catch the exception if it fails. Both are valid; Python's approach leads to less boilerplate in common cases.

Slices/Maps vs Lists/Dicts​

package main

import "fmt"

func main() {
// Slices
names := []string{"Alice", "Bob", "Charlie"}
names = append(names, "Diana")
fmt.Println(names[1:3]) // [Bob Charlie]

// Maps
ages := map[string]int{
"Alice": 30,
"Bob": 25,
}
ages["Charlie"] = 35

if age, ok := ages["Alice"]; ok {
fmt.Printf("Alice is %d\n", age)
}

// Range over map
for name, age := range ages {
fmt.Printf("%s is %d\n", name, age)
}
}

Key differences:

  • []T β†’ list[T]. Python lists are dynamically sized (like Go slices, not arrays).
  • map[K]V β†’ dict[K, V]. Python dicts are hash maps, equivalent to Go maps.
  • ok idiom β†’ in operator. Python uses key in dict instead of val, ok := m[key].
  • append β†’ .append(). Python uses method syntax instead of the append built-in.
  • No make(). Python creates empty collections with [], {}, or list(), dict().

Interfaces vs Protocols​

Go's interfaces are structural and implicit. Python has typing.Protocol for the same concept.

package main

import "fmt"

// Interface satisfied implicitly (duck typing)
type Stringer interface {
String() string
}

type User struct {
Name string
}

func (u *User) String() string {
return u.Name
}

type Product struct {
Title string
}

func (p *Product) String() string {
return p.Title
}

// Accepts any type that implements Stringer
func describe(s Stringer) {
fmt.Println(s.String())
}

func main() {
describe(&User{Name: "Alice"})
describe(&Product{Title: "Widget"})
}

Key differences:

  • Both are structural. Neither Go interfaces nor Python protocols require explicit implements declarations.
  • interface{} β†’ Protocol or typing.Any. For fully dynamic values, use Any. For structural contracts, use Protocol.
  • Method naming. Go uses String() (capitalized). Python uses string() (lowercase, snake_case).
  • fmt.Stringer β†’ __str__. Python has a built-in string representation method (__str__) that is called automatically by print().

Goroutines/Channels vs asyncio​

package main

import (
"fmt"
"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}

func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}

// Send jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)

// Wait for workers
wg.Wait()
close(results)

// Collect results
for r := range results {
fmt.Println(r)
}
}

Key differences:

  • go func() β†’ asyncio.create_task() or asyncio.gather(). Python does not have lightweight OS threads; it uses cooperative coroutines.
  • chan β†’ asyncio.Queue. Python's async queue provides similar functionality but is cooperative, not preemptive.
  • sync.WaitGroup β†’ asyncio.gather(). Wait for multiple coroutines to complete.
  • close(chan) β†’ queue exhaustion. Python queues do not have a close primitive; you signal completion differently.
  • True parallelism vs cooperative. Go goroutines run on OS threads with true parallelism. Python asyncio runs on a single thread with cooperative scheduling.

See the Concurrency section below for a deeper comparison.


Project Structure​

Python projects follow a different convention than Go projects. Understanding the layout is critical for knowing where to put code and how imports work.

my-project/
β”œβ”€β”€ go.mod
β”œβ”€β”€ go.sum
β”œβ”€β”€ cmd/
β”‚ └── server/
β”‚ └── main.go # Entry point
β”œβ”€β”€ internal/
β”‚ β”œβ”€β”€ handler/
β”‚ β”‚ └── user_handler.go
β”‚ β”œβ”€β”€ service/
β”‚ β”‚ └── ai_service.go
β”‚ └── model/
β”‚ └── user.go
β”œβ”€β”€ pkg/
β”‚ └── utils/
β”‚ └── helpers.go
└── tests/
└── user_handler_test.go

Key Structural Differences​

  • cmd/ β†’ src/ layout. Go separates entry points into cmd/. Python uses a src/ layout where the package lives under src/<package_name>/.
  • internal/ β†’ package privacy. Go enforces internal package privacy at the compiler level. Python has no such enforcement; use naming conventions (leading underscore) and project structure.
  • pkg/ β†’ top-level modules. Go's pkg/ for shared libraries maps to top-level modules within the Python package.
  • go.mod at root β†’ pyproject.toml at root. Both live at the project root.
  • No go.sum equivalent in source. The uv.lock file is the lockfile, analogous to go.sum.

__init__.py β€” No Go Equivalent​

Every directory that contains Python code needs an __init__.py file (which can be empty). This is Python's way of saying "this directory is a package."

__init__.py Explained

In Go, any directory with .go files is implicitly part of a package. In Python, the __init__.py file is the marker that makes a directory importable. It can be empty, or it can contain code that runs when the package is imported (similar to a package-level init() function, but Python does not have init()).

Import Paths​

In Go, you import using module paths:

import (
"github.com/org/my-project/internal/handler"
"github.com/org/my-project/internal/service"
)

In Python, you import using package names relative to the package root:

from my_project.handlers.user_handler import handle_user
from my_project.services.ai_service import AIService

The import path starts from the package root (the directory containing __init__.py), not from the current file. This is one of the most common sources of confusion for Go developers.

Import Gotcha

Python imports are relative to the Python path (sys.path), not the file system. When you run uv run src/my_project/main.py, the src/ directory is on the path, so you import my_project.something, not src.my_project.something. This differs from Go where the module path is explicit in go.mod.


Our Tooling Stack​

Our team uses three tools from the Astral ecosystem. Together, they replace what would be multiple separate tools in the Go world.

ruff β€” Linting and Formatting​

ruff is an extremely fast Python linter and code formatter written in Rust. It replaces Flake8, isort, Black, pydocstyle, pyupgrade, autoflake, and dozens of plugins β€” all while running 10–100Γ— faster.

In Go terms, ruff replaces both golangci-lint and gofmt.

Key commands:

# Lint the project (like golangci-lint run)
uvx ruff check .

# Auto-fix fixable issues
uvx ruff check --fix .

# Format the project (like gofmt -w)
uvx ruff format .

# Lint and format in one go
uvx ruff check --fix . && uvx ruff format .

Configuration lives in pyproject.toml:

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D100", # Missing docstring on module
"D104", # Missing docstring on public package
]

[tool.ruff.lint.pydocstyle]
convention = "google"
ruff vs golangci-lint + gofmt

In Go, you run golangci-lint run for linting and gofmt -w for formatting. With ruff, one tool does everything with over 900 built-in rules, including import sorting (replaces goimports), docstring checking, and modernization.

ty β€” Type Checking​

ty is an extremely fast Python type checker and language server written in Rust. It is 10–100Γ— faster than mypy and Pyright, making it practical to run on every edit.

In Go terms, ty is the closest equivalent to go vet for type-level checks, but it operates on optional type hints rather than a fully static type system.

Key commands:

# Type check the project (like go vet)
uvx ty check

# Type check a single file
uvx ty check src/my_project/main.py

Configuration in pyproject.toml:

[tool.ty]
python-version = "3.12"
ty vs go vet

go vet finds suspicious constructs at compile time because Go is statically typed. ty finds type errors in type hints at analysis time because Python is dynamically typed. The key difference: Go's type system is mandatory and enforced at compile time. Python's type hints are optional and checked by a separate tool.

Pre-commit Hooks​

We wire ruff and ty into pre-commit hooks so that quality checks run automatically before every commit. A typical .pre-commit-config.yaml:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: https://github.com/astral-sh/ty
rev: v0.0.33
hooks:
- id: ty

Install the hooks:

uv add --dev pre-commit
uv run pre-commit install

After installation, ruff and ty run automatically on staged files whenever you run git commit.


Concurrency: goroutines vs asyncio​

This is the deepest conceptual shift. Go's concurrency model is based on CSP (Communicating Sequential Processes) with true parallelism. Python's asyncio is cooperative concurrency on a single thread.

Concept Mapping​

Go ConceptPython asyncio EquivalentNotes
go func()asyncio.create_task()Schedule a coroutine for execution
chanasyncio.QueueAsync-safe message passing
sync.WaitGroupasyncio.gather()Wait for multiple coroutines
selectasyncio.wait() / asyncio.selectorsMultiplex across async operations
context.Contextasyncio.Task cancellation / asyncio.timeoutTimeout and cancellation
sync.Mutexasyncio.LockMutual exclusion for async code
errgroupasyncio.gather() with return_exceptions=TrueRun coroutines with error aggregation

Spawning Concurrent Work​

package main

import (
"context"
"fmt"
"sync"
"time"
)

func fetch(ctx context.Context, url string, result *string, wg *sync.WaitGroup) {
defer wg.Done()
// Simulated work
time.Sleep(100 * time.Millisecond)
*result = fmt.Sprintf("data from %s", url)
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var wg sync.WaitGroup
var result1, result2 string

wg.Add(2)
go fetch(ctx, "https://api.example.com/users", &result1, &wg)
go fetch(ctx, "https://api.example.com/posts", &result2, &wg)

wg.Wait()
fmt.Println(result1, result2)
}

Channels and Message Passing​

package main

import "fmt"

func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}

func main() {
ch := make(chan int)

go producer(ch)

for val := range ch {
fmt.Println(val)
}
}

When to Use Each​

goroutines vs asyncio: Key Differences
  • Go goroutines run on OS threads managed by the Go runtime scheduler. They achieve true parallelism and are ideal for CPU-bound and I/O-bound work.
  • Python asyncio runs coroutines on a single thread with cooperative scheduling. It is ideal for I/O-bound work (network calls, file I/O) but does not help with CPU-bound tasks.
  • GIL. Python's Global Interpreter Lock means only one thread executes Python bytecode at a time. For CPU-bound parallelism in Python, use multiprocessing or offload to C extensions (which NumPy, PyTorch, etc. already do).
  • Cancellation. Go's context.Context is deeply integrated into the standard library. Python's asyncio has cancellation via task.cancel() and asyncio.timeout(), but it is not as pervasive.

Your First Day Checklist​

Here is a practical checklist to get you productive on day one:

  1. Install uv β€” curl -LsSf https://astral.sh/uv/install.sh | sh
  2. Clone the repository β€” git clone <repo-url> && cd <repo-name>
  3. Sync dependencies β€” uv sync (creates .venv/ and installs everything)
  4. Run the linter β€” uvx ruff check . (like golangci-lint run)
  5. Run the formatter β€” uvx ruff format . (like gofmt -w)
  6. Run the type checker β€” uvx ty check (like go vet)
  7. Run the tests β€” uv run pytest (like go test ./...)
  8. Install pre-commit hooks β€” uv run pre-commit install (automates steps 4–6 on every commit)
First Commit

After completing the checklist, make a small change β€” add a type hint, fix a lint warning, or write a test. Commit it and let the pre-commit hooks validate your work. This is your "Hello, World" of the Python workflow.


Key Takeaways​

Summary
  • uv replaces go mod. It manages packages, virtual environments, and lockfiles β€” all in one extremely fast tool.
  • pyproject.toml replaces go.mod. It holds project metadata, dependencies, and tool configuration in a single file.
  • Type hints + ty replace compile-time checking. Python type hints give you static analysis without sacrificing dynamic flexibility.
  • ruff replaces golangci-lint + gofmt. One tool handles linting, formatting, and import sorting with 900+ rules.
  • .venv/ replaces the Go module cache. Each project gets an isolated virtual environment.
  • uvx replaces go install for tools. Run tools without global installation.
  • Exceptions replace if err != nil. Python uses exception-based error handling instead of explicit error returns.
  • @dataclass replaces struct. Dataclasses auto-generate constructors and methods from type annotations.
  • asyncio replaces goroutines for I/O concurrency. Python's async model is cooperative, not preemptive.
  • __init__.py has no Go equivalent. It marks directories as importable packages.

Further Reading​